Creating import&export excel
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "OrgRole" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL DEFAULT '',
|
||||
"color" TEXT NOT NULL DEFAULT 'teal',
|
||||
"permissions" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"systemKey" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "OrgRole_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "OrgRole_organizationId_name_key" ON "OrgRole"("organizationId", "name");
|
||||
CREATE INDEX IF NOT EXISTS "OrgRole_organizationId_idx" ON "OrgRole"("organizationId");
|
||||
|
||||
ALTER TABLE "OrgRole" DROP CONSTRAINT IF EXISTS "OrgRole_organizationId_fkey";
|
||||
ALTER TABLE "OrgRole" ADD CONSTRAINT "OrgRole_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "isActive" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "lastLoginAt" TIMESTAMP(3);
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "orgRoleId" TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "User_orgRoleId_idx" ON "User"("orgRoleId");
|
||||
|
||||
ALTER TABLE "User" DROP CONSTRAINT IF EXISTS "User_orgRoleId_fkey";
|
||||
ALTER TABLE "User" ADD CONSTRAINT "User_orgRoleId_fkey" FOREIGN KEY ("orgRoleId") REFERENCES "OrgRole"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -150,6 +150,7 @@ model Organization {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
users User[]
|
||||
orgRoles OrgRole[]
|
||||
companies Company[]
|
||||
people Person[]
|
||||
pipelines Pipeline[]
|
||||
@@ -199,6 +200,10 @@ model User {
|
||||
role UserRole @default(sales)
|
||||
/// مسیر فایل آواتار در فضای آپلود
|
||||
avatarStorageKey String?
|
||||
isActive Boolean @default(true)
|
||||
lastLoginAt DateTime?
|
||||
orgRoleId String?
|
||||
orgRole OrgRole? @relation(fields: [orgRoleId], references: [id], onDelete: SetNull)
|
||||
emailVerifiedAt DateTime?
|
||||
mobileVerifiedAt DateTime?
|
||||
organizationId String
|
||||
@@ -229,6 +234,25 @@ model User {
|
||||
@@unique([mobile])
|
||||
@@index([organizationId])
|
||||
@@index([email])
|
||||
@@index([orgRoleId])
|
||||
}
|
||||
|
||||
model OrgRole {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
description String @default("")
|
||||
color String @default("teal")
|
||||
permissions String[] @default([])
|
||||
/// owner | admin | sales | support — نقشهای سیستمی قابل حذف نیستند
|
||||
systemKey String?
|
||||
users User[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([organizationId, name])
|
||||
@@index([organizationId])
|
||||
}
|
||||
|
||||
model Company {
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { ActivityType, ImportContactsResult } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { assigneeWhere } from '../common/record-scope';
|
||||
import { normalizePhone } from '../contacts/normalize';
|
||||
import {
|
||||
formatClock,
|
||||
formatJalaliDate,
|
||||
parseJalaliDateTime,
|
||||
readMappedRows,
|
||||
stripHtml,
|
||||
writeXlsx,
|
||||
} from '../common/excel';
|
||||
|
||||
const ACTIVITY_COLUMNS = [
|
||||
{ header: '_type', key: '_type', width: 12 },
|
||||
{ header: 'نوع فعالیت', key: 'kind', width: 16 },
|
||||
{ header: 'عنوان فعالیت', key: 'title', width: 24 },
|
||||
{ header: 'متن فعالیت', key: 'body', width: 36 },
|
||||
{ header: 'نتیجه فعالیت', key: 'result', width: 20 },
|
||||
{ header: 'مسئول اجرا فعالیت', key: 'assignee', width: 18 },
|
||||
{ header: 'ثبت کننده فعالیت', key: 'creator', width: 18 },
|
||||
{ header: 'تاریخ برنامه ریزی شده اجرا فعالیت', key: 'dueDate', width: 18 },
|
||||
{ header: 'زمان برنامه ریزی شده اجرا فعالیت', key: 'dueTime', width: 14 },
|
||||
{ header: 'وضعیت اجرای فعالیت', key: 'status', width: 16 },
|
||||
{ header: 'تاریخ انجام شدن فعالیت', key: 'doneDate', width: 16 },
|
||||
{ header: 'کد معامله فعالیت', key: 'dealCode', width: 14 },
|
||||
{ header: 'کد کارت فعالیت', key: 'cardCode', width: 14 },
|
||||
{ header: 'زمان انجام شدن فعالیت', key: 'doneTime', width: 14 },
|
||||
{ header: 'اشخاص فعالیت', key: 'personName', width: 18 },
|
||||
{ header: 'کد اشخاص فعالیت', key: 'personCode', width: 14 },
|
||||
{ header: 'موبایل اشخاص فعالیت', key: 'personMobile', width: 16 },
|
||||
{ header: 'شرکت های فعالیت', key: 'companyName', width: 20 },
|
||||
{ header: 'کد شرکت های فعالیت', key: 'companyCode', width: 14 },
|
||||
{ header: 'معامله فعالیت_1', key: 'dealTitle', width: 24 },
|
||||
{ header: 'کارت فعالیت_1', key: 'cardTitle', width: 20 },
|
||||
{ header: 'هشتگ', key: 'hashtag', width: 16 },
|
||||
];
|
||||
|
||||
const ACTIVITY_ALIASES: Record<string, string> = {
|
||||
_type: '_type',
|
||||
type: '_type',
|
||||
نوعفعالیت: 'kind',
|
||||
عنوانفعالیت: 'title',
|
||||
عنوان: 'title',
|
||||
متنفعالیت: 'body',
|
||||
نتیجهفعالیت: 'result',
|
||||
مسئولاجرافعالیت: 'assignee',
|
||||
ثبتکنندهفعالیت: 'creator',
|
||||
تاریخبرنامهریزیشدهاجرافعالیت: 'dueDate',
|
||||
زمانبرنامهریزیشدهاجرافعالیت: 'dueTime',
|
||||
وضعیتاجرایفعالیت: 'status',
|
||||
تاریخانجامشدنفعالیت: 'doneDate',
|
||||
زمانانجامشدنفعالیت: 'doneTime',
|
||||
کدمعاملهفعالیت: 'dealCode',
|
||||
کدکارتفعالیت: 'cardCode',
|
||||
اشخاصفعالیت: 'personName',
|
||||
کداشخاصفعالیت: 'personCode',
|
||||
موبایلاشخاصفعالیت: 'personMobile',
|
||||
شرکتهایفعالیت: 'companyName',
|
||||
کدشرکتهایفعالیت: 'companyCode',
|
||||
معاملهفعالیت1: 'dealTitle',
|
||||
کارتفعالیت1: 'cardTitle',
|
||||
هشتگ: 'hashtag',
|
||||
};
|
||||
|
||||
const TYPE_LABEL: Record<ActivityType, string> = {
|
||||
call: 'تماس با مشتری',
|
||||
meeting: 'جلسه',
|
||||
task: 'پیگیری',
|
||||
note: 'یادداشت',
|
||||
};
|
||||
|
||||
function parseType(raw?: string): ActivityType {
|
||||
const v = (raw ?? '').trim();
|
||||
if (/تماس|call/i.test(v)) return 'call';
|
||||
if (/جلسه|ملاقات|meeting/i.test(v)) return 'meeting';
|
||||
if (/یادداشت|پیام|note/i.test(v)) return 'note';
|
||||
return 'task';
|
||||
}
|
||||
|
||||
function parseStatus(raw?: string): 'open' | 'done' | 'canceled' {
|
||||
const v = (raw ?? '').trim();
|
||||
if (/انجام\s*شده|done/i.test(v)) return 'done';
|
||||
if (/لغو|canceled|کنسل/i.test(v)) return 'canceled';
|
||||
return 'open';
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
if (status === 'done') return 'انجام شده';
|
||||
if (status === 'canceled') return 'لغو شده';
|
||||
return 'انجام نشده';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ActivitiesExcelService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async export(actor: Actor): Promise<Buffer> {
|
||||
const rows = await this.prisma.activity.findMany({
|
||||
where: { organizationId: actor.organizationId, ...assigneeWhere(actor) },
|
||||
include: {
|
||||
person: { select: { id: true, firstName: true, lastName: true, mobile: true } },
|
||||
company: { select: { id: true, name: true } },
|
||||
deal: { select: { id: true, title: true } },
|
||||
assignee: { select: { fullName: true } },
|
||||
createdBy: { select: { fullName: true } },
|
||||
},
|
||||
orderBy: [{ dueAt: 'desc' }, { createdAt: 'desc' }],
|
||||
take: 10000,
|
||||
});
|
||||
|
||||
return writeXlsx(
|
||||
'Activity',
|
||||
ACTIVITY_COLUMNS,
|
||||
rows.map((row) => ({
|
||||
_type: 'Activity',
|
||||
kind: TYPE_LABEL[row.type],
|
||||
title: row.title,
|
||||
body: row.description ?? '',
|
||||
result: '',
|
||||
assignee: row.assignee?.fullName ?? '',
|
||||
creator: row.createdBy?.fullName ?? '',
|
||||
dueDate: formatJalaliDate(row.dueAt),
|
||||
dueTime: formatClock(row.dueAt),
|
||||
status: statusLabel(row.status),
|
||||
doneDate: formatJalaliDate(row.completedAt),
|
||||
dealCode: row.deal?.id ?? '',
|
||||
cardCode: '',
|
||||
doneTime: formatClock(row.completedAt),
|
||||
personName: row.person
|
||||
? `${row.person.firstName} ${row.person.lastName}`.trim()
|
||||
: '',
|
||||
personCode: row.person?.id ?? '',
|
||||
personMobile: row.person?.mobile ?? '',
|
||||
companyName: row.company?.name ?? '',
|
||||
companyCode: row.company?.id ?? '',
|
||||
dealTitle: row.deal?.title ?? '',
|
||||
cardTitle: '',
|
||||
hashtag: '',
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async import(actor: Actor, file: Express.Multer.File): Promise<ImportContactsResult> {
|
||||
const rows = await readMappedRows(file, ACTIVITY_ALIASES);
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
const rowNum = i + 2;
|
||||
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'activity') {
|
||||
continue;
|
||||
}
|
||||
const title = (row.title || row.kind || '').trim();
|
||||
if (title.length < 2) {
|
||||
result.errors.push({ row: rowNum, message: 'عنوان فعالیت الزامی است' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const body = stripHtml([row.body, row.result, row.hashtag, row.cardTitle]
|
||||
.filter(Boolean)
|
||||
.join('\n'));
|
||||
const dueAt = parseJalaliDateTime(row.dueDate, row.dueTime);
|
||||
const doneAt = parseJalaliDateTime(row.doneDate, row.doneTime);
|
||||
const status = parseStatus(row.status);
|
||||
|
||||
try {
|
||||
const personId = await this.findPersonId(actor.organizationId, row);
|
||||
const companyId = await this.findCompanyId(actor.organizationId, row.companyName);
|
||||
const dealId = await this.findDealId(actor.organizationId, row.dealTitle);
|
||||
const assigneeId =
|
||||
(await this.findUserId(actor.organizationId, row.assignee)) ?? actor.userId;
|
||||
const creatorId =
|
||||
(await this.findUserId(actor.organizationId, row.creator)) ?? actor.userId;
|
||||
|
||||
const duplicate = dueAt
|
||||
? await this.prisma.activity.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
title,
|
||||
dueAt,
|
||||
personId: personId ?? null,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
if (duplicate) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.prisma.activity.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
title,
|
||||
type: parseType(row.kind),
|
||||
status,
|
||||
description: body || null,
|
||||
dueAt,
|
||||
completedAt: status === 'done' ? doneAt ?? new Date() : null,
|
||||
reminderNotifyApp: false,
|
||||
reminderNotifySms: false,
|
||||
personId,
|
||||
companyId,
|
||||
dealId,
|
||||
assigneeId,
|
||||
createdById: creatorId,
|
||||
},
|
||||
});
|
||||
result.created += 1;
|
||||
} catch (e) {
|
||||
result.errors.push({
|
||||
row: rowNum,
|
||||
message: e instanceof Error ? e.message : 'خطای ناشناخته',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async findPersonId(organizationId: string, row: Record<string, string>) {
|
||||
const mobile = normalizePhone(row.personMobile);
|
||||
if (mobile) {
|
||||
const byMobile = await this.prisma.person.findFirst({
|
||||
where: { organizationId, mobile: { contains: mobile.replace(/^\+98|^98|^0/, '') } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (byMobile) return byMobile.id;
|
||||
}
|
||||
const name = (row.personName ?? '').trim();
|
||||
if (name) {
|
||||
const parts = name.split(/\s+/);
|
||||
const byName = await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [
|
||||
{ lastName: { contains: name, mode: 'insensitive' } },
|
||||
{ firstName: { contains: parts[0]!, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (byName) return byName.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async findCompanyId(organizationId: string, name?: string) {
|
||||
const n = name?.trim();
|
||||
if (!n) return null;
|
||||
const row = await this.prisma.company.findFirst({
|
||||
where: { organizationId, name: { equals: n, mode: 'insensitive' } },
|
||||
select: { id: true },
|
||||
});
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
private async findDealId(organizationId: string, title?: string) {
|
||||
const t = title?.trim();
|
||||
if (!t) return null;
|
||||
const row = await this.prisma.deal.findFirst({
|
||||
where: { organizationId, title: { equals: t, mode: 'insensitive' } },
|
||||
select: { id: true },
|
||||
});
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
private async findUserId(organizationId: string, name?: string) {
|
||||
const n = name?.trim();
|
||||
if (!n) return null;
|
||||
const row = await this.prisma.user.findFirst({
|
||||
where: { organizationId, fullName: { contains: n, mode: 'insensitive' } },
|
||||
select: { id: true },
|
||||
});
|
||||
return row?.id ?? null;
|
||||
}
|
||||
}
|
||||
@@ -7,32 +7,43 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { ActivitiesService } from './activities.service';
|
||||
import { ActivitiesExcelService } from './activities-excel.service';
|
||||
import {
|
||||
CreateActivityDto,
|
||||
ListActivitiesQueryDto,
|
||||
UpdateActivityDto,
|
||||
} from './dto';
|
||||
|
||||
type AuthUser = { organizationId: string; userId: string; role: string };
|
||||
import { brand } from '@mento/shared';
|
||||
|
||||
@Controller('activities')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ActivitiesController {
|
||||
constructor(private readonly activitiesService: ActivitiesService) {}
|
||||
constructor(
|
||||
private readonly activitiesService: ActivitiesService,
|
||||
private readonly excelService: ActivitiesExcelService,
|
||||
) {}
|
||||
|
||||
@Get('summary')
|
||||
summary(@CurrentUser() user: AuthUser) {
|
||||
summary(@CurrentUser() user: Actor) {
|
||||
return this.activitiesService.summary(user.organizationId, user);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@CurrentUser() user: Actor,
|
||||
@Query() query: ListActivitiesQueryDto,
|
||||
) {
|
||||
return this.activitiesService.list(
|
||||
@@ -54,13 +65,41 @@ export class ActivitiesController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
async export(
|
||||
@CurrentUser() user: Actor,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const buffer = await this.excelService.export(user);
|
||||
res.set({
|
||||
'Content-Type':
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition': `attachment; filename="${brand.filePrefix}-activities.xlsx"`,
|
||||
});
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 15_000_000 },
|
||||
}),
|
||||
)
|
||||
import(
|
||||
@CurrentUser() user: Actor,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.excelService.import(user, file);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
get(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.activitiesService.get(user.organizationId, id, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreateActivityDto) {
|
||||
create(@CurrentUser() user: Actor, @Body() dto: CreateActivityDto) {
|
||||
return this.activitiesService.create(
|
||||
user.organizationId,
|
||||
user.userId,
|
||||
@@ -71,7 +110,7 @@ export class ActivitiesController {
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateActivityDto,
|
||||
) {
|
||||
@@ -79,12 +118,12 @@ export class ActivitiesController {
|
||||
}
|
||||
|
||||
@Patch(':id/complete')
|
||||
complete(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
complete(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.activitiesService.complete(user.organizationId, id, user);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
async remove(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
await this.activitiesService.remove(user.organizationId, id, user);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ActivitiesController } from './activities.controller';
|
||||
import { ActivitiesService } from './activities.service';
|
||||
import { ActivitiesExcelService } from './activities-excel.service';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { AutomationsModule } from '../automations/automations.module';
|
||||
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
||||
@@ -8,7 +9,7 @@ import { MessageAutomationsModule } from '../message-automations/message-automat
|
||||
@Module({
|
||||
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule],
|
||||
controllers: [ActivitiesController],
|
||||
providers: [ActivitiesService],
|
||||
providers: [ActivitiesService, ActivitiesExcelService],
|
||||
exports: [ActivitiesService],
|
||||
})
|
||||
export class ActivitiesModule {}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ProductsModule } from './products/products.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
import { StorageModule } from './storage/storage.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { OrgRolesModule } from './org-roles/org-roles.module';
|
||||
import { LeadFormsModule } from './lead-forms/lead-forms.module';
|
||||
import { QuotesModule } from './quotes/quotes.module';
|
||||
import { InvoicesModule } from './invoices/invoices.module';
|
||||
@@ -45,6 +46,7 @@ import { FinanceModule } from './finance/finance.module';
|
||||
HealthModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
OrgRolesModule,
|
||||
OrganizationsModule,
|
||||
ContactsModule,
|
||||
PipelinesModule,
|
||||
|
||||
@@ -278,6 +278,9 @@ export class AuthService {
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('اطلاعات ورود نادرست است');
|
||||
}
|
||||
if (user.isActive === false) {
|
||||
throw new UnauthorizedException('حساب کاربری غیرفعال است');
|
||||
}
|
||||
|
||||
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
||||
if (!ok) {
|
||||
@@ -298,6 +301,9 @@ export class AuthService {
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('حسابی با این موبایل یافت نشد');
|
||||
}
|
||||
if (user.isActive === false) {
|
||||
throw new UnauthorizedException('حساب کاربری غیرفعال است');
|
||||
}
|
||||
|
||||
await this.throttleChallenges('login', user.email, mobile);
|
||||
|
||||
@@ -352,6 +358,9 @@ export class AuthService {
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
if (user.isActive === false) {
|
||||
throw new UnauthorizedException('حساب کاربری غیرفعال است');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await this.prisma.authChallenge.update({
|
||||
@@ -600,6 +609,10 @@ export class AuthService {
|
||||
organizationId: user.organizationId,
|
||||
role: user.role,
|
||||
};
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
return {
|
||||
accessToken: this.jwt.sign(payload),
|
||||
user: await this.toAuthUserDto(user),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export type JwtPayload = {
|
||||
sub: string;
|
||||
@@ -11,7 +12,10 @@ export type JwtPayload = {
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(config: ConfigService) {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
@@ -19,14 +23,21 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: JwtPayload & { typ?: string; mobile?: string }) {
|
||||
async validate(payload: JwtPayload & { typ?: string; mobile?: string }) {
|
||||
if (payload.typ === 'portal') {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: { id: true, organizationId: true, role: true, isActive: true },
|
||||
});
|
||||
if (!user || user.isActive === false) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return {
|
||||
userId: payload.sub,
|
||||
organizationId: payload.organizationId,
|
||||
role: payload.role,
|
||||
userId: user.id,
|
||||
organizationId: user.organizationId,
|
||||
role: user.role,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
export function cellStr(value: ExcelJS.CellValue | undefined): string {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return String(value).trim();
|
||||
}
|
||||
if (typeof value === 'object' && 'richText' in value && Array.isArray(value.richText)) {
|
||||
return value.richText.map((part) => part.text).join('').trim();
|
||||
}
|
||||
if (typeof value === 'object' && 'text' in value && typeof value.text === 'string') {
|
||||
return value.text.trim();
|
||||
}
|
||||
if (typeof value === 'object' && 'result' in value && value.result != null) {
|
||||
return String(value.result).trim();
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
export function normalizeHeader(raw: string) {
|
||||
return raw
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[_-]+/g, '')
|
||||
.replace(//g, '')
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[()]/g, '');
|
||||
}
|
||||
|
||||
export function stripHtml(raw: string) {
|
||||
return raw
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function toAsciiDigits(value: string) {
|
||||
return value
|
||||
.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)))
|
||||
.replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)));
|
||||
}
|
||||
|
||||
function div(a: number, b: number) {
|
||||
return Math.trunc(a / b);
|
||||
}
|
||||
|
||||
function mod(a: number, b: number) {
|
||||
return a - Math.trunc(a / b) * b;
|
||||
}
|
||||
|
||||
export function toJalali(gy: number, gm: number, gd: number) {
|
||||
const gdm = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
|
||||
const gy2 = gm > 2 ? gy + 1 : gy;
|
||||
let days =
|
||||
355666 +
|
||||
365 * gy +
|
||||
div(gy2 + 3, 4) -
|
||||
div(gy2 + 99, 100) +
|
||||
div(gy2 + 399, 400) +
|
||||
gd +
|
||||
gdm[gm - 1];
|
||||
let jy = -1595 + 33 * div(days, 12053);
|
||||
days %= 12053;
|
||||
jy += 4 * div(days, 1461);
|
||||
days %= 1461;
|
||||
if (days > 365) {
|
||||
jy += div(days - 1, 365);
|
||||
days = (days - 1) % 365;
|
||||
}
|
||||
const jm = days < 186 ? 1 + div(days, 31) : 7 + div(days - 186, 30);
|
||||
const jd = 1 + (days < 186 ? days % 31 : (days - 186) % 30);
|
||||
return { jy, jm, jd };
|
||||
}
|
||||
|
||||
export function toGregorian(jy: number, jm: number, jd: number) {
|
||||
const jy2 = jy + 1595;
|
||||
let days =
|
||||
-355668 +
|
||||
365 * jy2 +
|
||||
div(jy2, 33) * 8 +
|
||||
div(mod(jy2, 33) + 3, 4) +
|
||||
jd +
|
||||
(jm < 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186);
|
||||
let gy = 400 * div(days, 146097);
|
||||
days %= 146097;
|
||||
if (days > 36524) {
|
||||
gy += 100 * div(--days, 36524);
|
||||
days %= 36524;
|
||||
if (days >= 365) days += 1;
|
||||
}
|
||||
gy += 4 * div(days, 1461);
|
||||
days %= 1461;
|
||||
if (days > 365) {
|
||||
gy += div(days - 1, 365);
|
||||
days = (days - 1) % 365;
|
||||
}
|
||||
const gd = days + 1;
|
||||
const salA = [
|
||||
0,
|
||||
31,
|
||||
(gy % 4 === 0 && gy % 100 !== 0) || gy % 400 === 0 ? 29 : 28,
|
||||
31,
|
||||
30,
|
||||
31,
|
||||
30,
|
||||
31,
|
||||
31,
|
||||
30,
|
||||
31,
|
||||
30,
|
||||
31,
|
||||
];
|
||||
let gm = 0;
|
||||
let gDay = gd;
|
||||
for (let i = 1; i <= 12; i += 1) {
|
||||
const v = salA[i]!;
|
||||
if (gDay <= v) {
|
||||
gm = i;
|
||||
break;
|
||||
}
|
||||
gDay -= v;
|
||||
}
|
||||
return { gy, gm, gd: gDay };
|
||||
}
|
||||
|
||||
export function formatJalaliDate(d: Date | string | null | undefined) {
|
||||
if (!d) return '';
|
||||
const date = typeof d === 'string' ? new Date(d) : d;
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const { jy, jm, jd } = toJalali(date.getFullYear(), date.getMonth() + 1, date.getDate());
|
||||
return `${jy}/${String(jm).padStart(2, '0')}/${String(jd).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function formatClock(d: Date | string | null | undefined) {
|
||||
if (!d) return '';
|
||||
const date = typeof d === 'string' ? new Date(d) : d;
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function parseJalaliDateTime(dateRaw?: string, timeRaw?: string): Date | null {
|
||||
const dateText = toAsciiDigits(dateRaw ?? '').trim();
|
||||
if (!dateText) return null;
|
||||
const match = /^(\d{3,4})[\/\-](\d{1,2})[\/\-](\d{1,2})$/.exec(dateText);
|
||||
if (!match) {
|
||||
const fallback = new Date(dateText);
|
||||
return Number.isNaN(fallback.getTime()) ? null : fallback;
|
||||
}
|
||||
const jy = Number(match[1]);
|
||||
const jm = Number(match[2]);
|
||||
const jd = Number(match[3]);
|
||||
const { gy, gm, gd } = toGregorian(jy, jm, jd);
|
||||
let hh = 0;
|
||||
let mm = 0;
|
||||
const timeText = toAsciiDigits(timeRaw ?? '').trim();
|
||||
const tm = /^(\d{1,2}):(\d{2})/.exec(timeText);
|
||||
if (tm) {
|
||||
hh = Number(tm[1]);
|
||||
mm = Number(tm[2]);
|
||||
}
|
||||
return new Date(gy, gm - 1, gd, hh, mm, 0, 0);
|
||||
}
|
||||
|
||||
export async function workbookFromUpload(file: Express.Multer.File | undefined) {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('فایل اکسل انتخاب نشده است');
|
||||
}
|
||||
const wb = new ExcelJS.Workbook();
|
||||
await wb.xlsx.load(Uint8Array.from(file.buffer) as unknown as ExcelJS.Buffer);
|
||||
const sheet = wb.worksheets[0];
|
||||
if (!sheet) throw new BadRequestException('شیت اکسل پیدا نشد');
|
||||
return sheet;
|
||||
}
|
||||
|
||||
export async function readMappedRows(
|
||||
file: Express.Multer.File,
|
||||
aliases: Record<string, string>,
|
||||
): Promise<Record<string, string>[]> {
|
||||
const sheet = await workbookFromUpload(file);
|
||||
const headerRow = sheet.getRow(1);
|
||||
const map = new Map<number, string>();
|
||||
headerRow.eachCell((cell, col) => {
|
||||
const raw = cellStr(cell.value);
|
||||
if (!raw) return;
|
||||
const compact = normalizeHeader(raw);
|
||||
const key = aliases[compact] ?? aliases[raw.trim()] ?? aliases[normalizeHeader(raw)];
|
||||
if (key) map.set(col, key);
|
||||
});
|
||||
if (!map.size) {
|
||||
throw new BadRequestException(
|
||||
'ستونهای فایل شناسایی نشد. از قالب خروجی همین سیستم یا خروجی دیدار استفاده کنید.',
|
||||
);
|
||||
}
|
||||
|
||||
const rows: Record<string, string>[] = [];
|
||||
sheet.eachRow((row, rowNumber) => {
|
||||
if (rowNumber === 1) return;
|
||||
const item: Record<string, string> = {};
|
||||
let any = false;
|
||||
for (const [col, key] of map) {
|
||||
const value = cellStr(row.getCell(col).value);
|
||||
if (value) any = true;
|
||||
if (value || item[key] == null) item[key] = value;
|
||||
}
|
||||
if (any) rows.push(item);
|
||||
});
|
||||
if (!rows.length) {
|
||||
throw new BadRequestException('هیچ ردیف دادهای در فایل نیست');
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function writeXlsx(
|
||||
sheetName: string,
|
||||
columns: Array<{ header: string; key: string; width?: number }>,
|
||||
rows: Array<Record<string, string | number | null | undefined>>,
|
||||
): Promise<Buffer> {
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const sheet = wb.addWorksheet(sheetName);
|
||||
sheet.columns = columns;
|
||||
for (const row of rows) sheet.addRow(row);
|
||||
const buf = await wb.xlsx.writeBuffer();
|
||||
return Buffer.from(buf);
|
||||
}
|
||||
@@ -1,27 +1,17 @@
|
||||
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||
import ExcelJS from 'exceljs';
|
||||
import type { ImportContactsResult, PersonMood } from '@mento/shared';
|
||||
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { ownerWhere } from '../common/record-scope';
|
||||
import {
|
||||
formatJalaliDate,
|
||||
readMappedRows,
|
||||
writeXlsx,
|
||||
} from '../common/excel';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { PeopleService } from './people.service';
|
||||
|
||||
function cellStr(value: ExcelJS.CellValue | undefined): string {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return String(value).trim();
|
||||
}
|
||||
if (typeof value === 'object' && 'text' in value && typeof value.text === 'string') {
|
||||
return value.text.trim();
|
||||
}
|
||||
if (typeof value === 'object' && 'result' in value && value.result != null) {
|
||||
return String(value.result).trim();
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function parseMood(raw?: string): PersonMood | undefined {
|
||||
const value = raw?.trim();
|
||||
if (!value) return undefined;
|
||||
@@ -29,45 +19,103 @@ function parseMood(raw?: string): PersonMood | undefined {
|
||||
return PERSON_MOODS.find((mood) => PERSON_MOOD_LABELS[mood] === value);
|
||||
}
|
||||
|
||||
function normalizeHeader(raw: string) {
|
||||
return raw.trim().toLowerCase().replace(/\s+/g, '');
|
||||
function splitDidarName(firstName: string, lastName: string, title?: string) {
|
||||
const first = firstName.trim();
|
||||
const last = lastName.trim();
|
||||
if (first && last) return { firstName: first.slice(0, 80), lastName: last.slice(0, 80) };
|
||||
const full = (first || last || title || '').trim();
|
||||
const parts = full.split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
return {
|
||||
firstName: parts[0]!.slice(0, 80),
|
||||
lastName: parts.slice(1).join(' ').slice(0, 80),
|
||||
};
|
||||
}
|
||||
if (full) return { firstName: full.slice(0, 80), lastName: full.slice(0, 80) };
|
||||
return null;
|
||||
}
|
||||
|
||||
const PERSON_ALIASES: Record<string, string> = {
|
||||
firstname: 'firstName',
|
||||
نام: 'firstName',
|
||||
ناممشتری: 'firstName',
|
||||
lastname: 'lastName',
|
||||
نامخانوادگی: 'lastName',
|
||||
'نام خانوادگی': 'lastName',
|
||||
نامخانوادگیمشتری: 'lastName',
|
||||
عنوانمشتری: 'title',
|
||||
email: 'email',
|
||||
ایمیل: 'email',
|
||||
ایمیلمشتری: 'email',
|
||||
phone: 'phone',
|
||||
تلفن: 'phone',
|
||||
تلفنثابت: 'phone',
|
||||
تلفنثابتمشتری: 'phone',
|
||||
mobile: 'mobile',
|
||||
موبایل: 'mobile',
|
||||
تلفنهمراهمشتری: 'mobile',
|
||||
position: 'position',
|
||||
سمت: 'position',
|
||||
سمت1: 'position',
|
||||
company: 'companyName',
|
||||
companyname: 'companyName',
|
||||
نامشرکت: 'companyName',
|
||||
شرکت: 'companyName',
|
||||
notes: 'notes',
|
||||
یادداشت: 'notes',
|
||||
توضیحاتشخص: 'notes',
|
||||
mood: 'mood',
|
||||
وضعیت: 'mood',
|
||||
وضعیتمشتری: 'mood',
|
||||
'وضعیت مشتری': 'mood',
|
||||
گروهبندیشخص: 'mood',
|
||||
گروهمشتری: 'group',
|
||||
آدرس: 'address',
|
||||
آدرس1: 'address',
|
||||
آدرسمنزل: 'homeAddress',
|
||||
آدرمنزل: 'homeAddress',
|
||||
کدپستی: 'postalCode',
|
||||
کدپستی1: 'postalCode',
|
||||
استانفیلدپیشفرض: 'province',
|
||||
استان: 'province',
|
||||
شهرستان: 'city',
|
||||
شهر1: 'city',
|
||||
کدمشتری: 'customerCode',
|
||||
کددیدارمشتری: 'didarId',
|
||||
کدملیمشتری: 'nationalId',
|
||||
تاریخثبتمشتری: 'registeredAt',
|
||||
تاریختولدمشتری: 'birthDate',
|
||||
تاریختولد1: 'birthDate',
|
||||
تاریختولد: 'birthDate',
|
||||
منبعاطلاعات: 'source',
|
||||
نوعمشتری: 'customerType',
|
||||
type: '_type',
|
||||
_type: '_type',
|
||||
آیدیاینستاگرام: 'instagram',
|
||||
معرف: 'referrer',
|
||||
سایتهایمشتری: 'website',
|
||||
شمارهفکس: 'fax',
|
||||
تلفن2: 'phone2',
|
||||
تلفنهایدیگرمشتری: 'otherPhones',
|
||||
مسئولمشتری: 'ownerName',
|
||||
تلفنثابتشرکت: 'companyPhone',
|
||||
کددیدارشرکت: 'companyDidarId',
|
||||
مجوزمشاهدهمشتری: 'visibility',
|
||||
ایمیلهایدیگرمشتری: 'otherEmails',
|
||||
کد1: 'customerCode',
|
||||
گروه1: 'group',
|
||||
تماسهمکار: 'colleagueContact',
|
||||
شرکتقبلی: 'previousCompany',
|
||||
ستاره: 'star',
|
||||
};
|
||||
|
||||
const COMPANY_ALIASES: Record<string, string> = {
|
||||
name: 'name',
|
||||
نام: 'name',
|
||||
نامشرکت: 'name',
|
||||
'نام شرکت': 'name',
|
||||
nationalid: 'nationalId',
|
||||
شناسهملی: 'nationalId',
|
||||
'شناسه ملی': 'nationalId',
|
||||
phone: 'phone',
|
||||
تلفن: 'phone',
|
||||
تلفنثابتشرکت: 'phone',
|
||||
email: 'email',
|
||||
ایمیل: 'email',
|
||||
website: 'website',
|
||||
@@ -78,6 +126,83 @@ const COMPANY_ALIASES: Record<string, string> = {
|
||||
یادداشت: 'notes',
|
||||
};
|
||||
|
||||
const CONTACT_COLUMNS = [
|
||||
{ header: '_type', key: '_type', width: 12 },
|
||||
{ header: 'کد دیدار مشتری', key: 'didarId', width: 16 },
|
||||
{ header: 'کد مشتری', key: 'customerCode', width: 14 },
|
||||
{ header: 'عنوان مشتری', key: 'title', width: 16 },
|
||||
{ header: 'نام مشتری', key: 'firstName', width: 16 },
|
||||
{ header: 'نام خانوادگی مشتری', key: 'lastName', width: 22 },
|
||||
{ header: 'ایمیل مشتری', key: 'email', width: 24 },
|
||||
{ header: 'شماره فکس', key: 'fax', width: 14 },
|
||||
{ header: 'سمت', key: 'position', width: 16 },
|
||||
{ header: 'کد پستی', key: 'postalCode', width: 12 },
|
||||
{ header: 'توضیحات شخص', key: 'notes', width: 32 },
|
||||
{ header: 'تلفن همراه مشتری', key: 'mobile', width: 16 },
|
||||
{ header: 'استان (فیلد پیش فرض)', key: 'province', width: 16 },
|
||||
{ header: 'شهرستان', key: 'city', width: 14 },
|
||||
{ header: 'تلفن ثابت مشتری', key: 'phone', width: 16 },
|
||||
{ header: 'تلفن ثابت شرکت', key: 'companyPhone', width: 16 },
|
||||
{ header: 'نام شرکت', key: 'companyName', width: 24 },
|
||||
{ header: 'کد دیدار شرکت', key: 'companyDidarId', width: 14 },
|
||||
{ header: 'مسئول مشتری', key: 'ownerName', width: 16 },
|
||||
{ header: 'مجوز مشاهده مشتری', key: 'visibility', width: 18 },
|
||||
{ header: 'کد ملی مشتری', key: 'nationalId', width: 16 },
|
||||
{ header: 'تاریخ ثبت مشتری', key: 'registeredAt', width: 16 },
|
||||
{ header: 'تاریخ تولد مشتری', key: 'birthDate', width: 16 },
|
||||
{ header: 'گروه بندی شخص', key: 'mood', width: 16 },
|
||||
{ header: 'تلفن های دیگر مشتری', key: 'otherPhones', width: 18 },
|
||||
{ header: 'آدرس', key: 'address', width: 28 },
|
||||
{ header: 'سایت های مشتری', key: 'website', width: 20 },
|
||||
{ header: 'ایمیل های دیگر مشتری', key: 'otherEmails', width: 20 },
|
||||
{ header: 'سمت_1', key: 'position2', width: 14 },
|
||||
{ header: 'شهر_1', key: 'city2', width: 12 },
|
||||
{ header: 'کد_1', key: 'code2', width: 12 },
|
||||
{ header: 'گروه_1', key: 'group2', width: 12 },
|
||||
{ header: 'آدرس_1', key: 'address2', width: 20 },
|
||||
{ header: 'تلفن2', key: 'phone2', width: 14 },
|
||||
{ header: 'کدپستی', key: 'postalCode2', width: 12 },
|
||||
{ header: 'معرف', key: 'referrer', width: 14 },
|
||||
{ header: 'آدرس منزل', key: 'homeAddress', width: 20 },
|
||||
{ header: 'تاریخ تولد_1', key: 'birthDate2', width: 14 },
|
||||
{ header: 'آی دی اینستاگرام', key: 'instagram', width: 16 },
|
||||
{ header: 'گروه مشتری', key: 'group', width: 16 },
|
||||
{ header: 'تماس همکار', key: 'colleagueContact', width: 16 },
|
||||
{ header: 'شرکت قبلی', key: 'previousCompany', width: 16 },
|
||||
{ header: 'نوع مشتری', key: 'customerType', width: 14 },
|
||||
{ header: 'ستاره', key: 'star', width: 10 },
|
||||
{ header: 'منبع اطلاعات', key: 'source', width: 16 },
|
||||
];
|
||||
|
||||
const CUSTOM_KEYS = [
|
||||
'didarId',
|
||||
'customerCode',
|
||||
'title',
|
||||
'fax',
|
||||
'postalCode',
|
||||
'province',
|
||||
'city',
|
||||
'address',
|
||||
'nationalId',
|
||||
'birthDate',
|
||||
'group',
|
||||
'otherPhones',
|
||||
'website',
|
||||
'otherEmails',
|
||||
'instagram',
|
||||
'referrer',
|
||||
'source',
|
||||
'customerType',
|
||||
'homeAddress',
|
||||
'colleagueContact',
|
||||
'previousCompany',
|
||||
'star',
|
||||
'phone2',
|
||||
'companyDidarId',
|
||||
'companyPhone',
|
||||
'visibility',
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class ContactsExcelService {
|
||||
constructor(
|
||||
@@ -88,51 +213,76 @@ export class ContactsExcelService {
|
||||
|
||||
async exportPeople(actor: Actor, q?: string): Promise<Buffer> {
|
||||
const { items } = await this.people.list(actor, q, 1, 5000);
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const sheet = wb.addWorksheet('اشخاص');
|
||||
sheet.columns = [
|
||||
{ header: 'نام', key: 'firstName', width: 16 },
|
||||
{ header: 'نام خانوادگی', key: 'lastName', width: 18 },
|
||||
{ header: 'موبایل', key: 'mobile', width: 16 },
|
||||
{ header: 'تلفن', key: 'phone', width: 16 },
|
||||
{ header: 'ایمیل', key: 'email', width: 28 },
|
||||
{ header: 'سمت', key: 'position', width: 18 },
|
||||
{ header: 'شرکت', key: 'companyName', width: 24 },
|
||||
{ header: 'وضعیت مشتری', key: 'mood', width: 18 },
|
||||
{ header: 'یادداشت', key: 'notes', width: 32 },
|
||||
];
|
||||
for (const p of items) {
|
||||
sheet.addRow({
|
||||
firstName: p.firstName,
|
||||
lastName: p.lastName,
|
||||
mobile: p.mobile ?? '',
|
||||
phone: p.phone ?? '',
|
||||
email: p.email ?? '',
|
||||
position: p.position ?? '',
|
||||
companyName: p.company?.name ?? '',
|
||||
mood: PERSON_MOOD_LABELS[p.mood || 'newcomer'],
|
||||
notes: p.notes ?? '',
|
||||
});
|
||||
}
|
||||
const buf = await wb.xlsx.writeBuffer();
|
||||
return Buffer.from(buf);
|
||||
return writeXlsx(
|
||||
'Contact',
|
||||
CONTACT_COLUMNS,
|
||||
items.map((p) => {
|
||||
const extra = (p.customFields ?? {}) as Record<string, string>;
|
||||
return {
|
||||
_type: 'Contact',
|
||||
didarId: extra.didarId ?? p.id,
|
||||
customerCode: extra.customerCode ?? '',
|
||||
title: extra.title ?? '',
|
||||
firstName: p.firstName,
|
||||
lastName: p.lastName,
|
||||
email: p.email ?? '',
|
||||
fax: extra.fax ?? '',
|
||||
position: p.position ?? extra.position ?? '',
|
||||
postalCode: extra.postalCode ?? '',
|
||||
notes: p.notes ?? '',
|
||||
mobile: p.mobile ?? '',
|
||||
province: extra.province ?? '',
|
||||
city: extra.city ?? '',
|
||||
phone: p.phone ?? '',
|
||||
companyPhone: extra.companyPhone ?? '',
|
||||
companyName: p.company?.name ?? '',
|
||||
companyDidarId: extra.companyDidarId ?? '',
|
||||
ownerName: p.owner?.fullName ?? '',
|
||||
visibility: 'همه افراد شرکت',
|
||||
nationalId: extra.nationalId ?? '',
|
||||
registeredAt: formatJalaliDate(p.createdAt),
|
||||
birthDate: extra.birthDate ?? '',
|
||||
mood: extra.group || PERSON_MOOD_LABELS[p.mood || 'newcomer'],
|
||||
otherPhones: extra.otherPhones ?? '',
|
||||
address: extra.address ?? '',
|
||||
website: extra.website ?? '',
|
||||
otherEmails: extra.otherEmails ?? '',
|
||||
position2: p.position ?? extra.position ?? '',
|
||||
city2: extra.city ?? '',
|
||||
code2: extra.customerCode ?? '',
|
||||
group2: extra.group ?? '',
|
||||
address2: extra.address ?? '',
|
||||
phone2: extra.phone2 ?? p.phone ?? '',
|
||||
postalCode2: extra.postalCode ?? '',
|
||||
referrer: extra.referrer ?? '',
|
||||
homeAddress: extra.homeAddress ?? '',
|
||||
birthDate2: extra.birthDate ?? '',
|
||||
instagram: extra.instagram ?? '',
|
||||
group: extra.group ?? '',
|
||||
colleagueContact: extra.colleagueContact ?? '',
|
||||
previousCompany: extra.previousCompany ?? '',
|
||||
customerType: extra.customerType ?? '',
|
||||
star: extra.star ?? '',
|
||||
source: extra.source ?? '',
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async exportCompanies(actor: Actor, q?: string): Promise<Buffer> {
|
||||
const { items } = await this.companies.list(actor, q, 1, 5000);
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const sheet = wb.addWorksheet('شرکتها');
|
||||
sheet.columns = [
|
||||
{ header: 'نام شرکت', key: 'name', width: 28 },
|
||||
{ header: 'شناسه ملی', key: 'nationalId', width: 16 },
|
||||
{ header: 'تلفن', key: 'phone', width: 16 },
|
||||
{ header: 'ایمیل', key: 'email', width: 28 },
|
||||
{ header: 'وبسایت', key: 'website', width: 24 },
|
||||
{ header: 'آدرس', key: 'address', width: 32 },
|
||||
{ header: 'یادداشت', key: 'notes', width: 32 },
|
||||
];
|
||||
for (const c of items) {
|
||||
sheet.addRow({
|
||||
return writeXlsx(
|
||||
'شرکتها',
|
||||
[
|
||||
{ header: 'نام شرکت', key: 'name', width: 28 },
|
||||
{ header: 'شناسه ملی', key: 'nationalId', width: 16 },
|
||||
{ header: 'تلفن', key: 'phone', width: 16 },
|
||||
{ header: 'ایمیل', key: 'email', width: 28 },
|
||||
{ header: 'وبسایت', key: 'website', width: 24 },
|
||||
{ header: 'آدرس', key: 'address', width: 32 },
|
||||
{ header: 'یادداشت', key: 'notes', width: 32 },
|
||||
],
|
||||
items.map((c) => ({
|
||||
name: c.name,
|
||||
nationalId: c.nationalId ?? '',
|
||||
phone: c.phone ?? '',
|
||||
@@ -140,36 +290,26 @@ export class ContactsExcelService {
|
||||
website: c.website ?? '',
|
||||
address: c.address ?? '',
|
||||
notes: c.notes ?? '',
|
||||
});
|
||||
}
|
||||
const buf = await wb.xlsx.writeBuffer();
|
||||
return Buffer.from(buf);
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async importPeople(
|
||||
actor: Actor,
|
||||
file: Express.Multer.File,
|
||||
): Promise<ImportContactsResult> {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('فایل اکسل انتخاب نشده است');
|
||||
}
|
||||
const rows = await this.readRows(file, PERSON_ALIASES);
|
||||
const result: ImportContactsResult = {
|
||||
created: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
};
|
||||
const rows = await readMappedRows(file, PERSON_ALIASES);
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
const rowNum = i + 2;
|
||||
const firstName = row.firstName?.trim() ?? '';
|
||||
const lastName = row.lastName?.trim() ?? '';
|
||||
if (!firstName || !lastName) {
|
||||
result.errors.push({
|
||||
row: rowNum,
|
||||
message: 'نام و نام خانوادگی الزامی است',
|
||||
});
|
||||
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'contact') {
|
||||
continue;
|
||||
}
|
||||
const names = splitDidarName(row.firstName ?? '', row.lastName ?? '', row.title);
|
||||
if (!names) {
|
||||
result.errors.push({ row: rowNum, message: 'نام مشتری الزامی است' });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -179,17 +319,24 @@ export class ContactsExcelService {
|
||||
companyId = await this.findOrCreateCompanyId(actor, companyName);
|
||||
}
|
||||
|
||||
const customFields: Record<string, string> = {};
|
||||
for (const key of CUSTOM_KEYS) {
|
||||
const value = row[key]?.trim();
|
||||
if (value) customFields[key] = value;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.people.create(actor, {
|
||||
firstName,
|
||||
lastName,
|
||||
firstName: names.firstName,
|
||||
lastName: names.lastName,
|
||||
email: row.email || undefined,
|
||||
phone: row.phone || undefined,
|
||||
phone: row.phone || row.phone2 || undefined,
|
||||
mobile: row.mobile || undefined,
|
||||
position: row.position || undefined,
|
||||
notes: row.notes || undefined,
|
||||
mood: parseMood(row.mood),
|
||||
mood: parseMood(row.mood) ?? parseMood(row.group),
|
||||
companyId,
|
||||
customFields,
|
||||
});
|
||||
result.created += 1;
|
||||
} catch (e) {
|
||||
@@ -211,15 +358,8 @@ export class ContactsExcelService {
|
||||
actor: Actor,
|
||||
file: Express.Multer.File,
|
||||
): Promise<ImportContactsResult> {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('فایل اکسل انتخاب نشده است');
|
||||
}
|
||||
const rows = await this.readRows(file, COMPANY_ALIASES);
|
||||
const result: ImportContactsResult = {
|
||||
created: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
};
|
||||
const rows = await readMappedRows(file, COMPANY_ALIASES);
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
@@ -229,7 +369,6 @@ export class ContactsExcelService {
|
||||
result.errors.push({ row: rowNum, message: 'نام شرکت الزامی است' });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.companies.create(actor, {
|
||||
name,
|
||||
@@ -256,10 +395,7 @@ export class ContactsExcelService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private async findOrCreateCompanyId(
|
||||
actor: Actor,
|
||||
name: string,
|
||||
): Promise<string> {
|
||||
private async findOrCreateCompanyId(actor: Actor, name: string): Promise<string> {
|
||||
const existing = await this.prisma.company.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
@@ -272,56 +408,4 @@ export class ContactsExcelService {
|
||||
const created = await this.companies.create(actor, { name });
|
||||
return created.id;
|
||||
}
|
||||
|
||||
private async readRows(
|
||||
file: Express.Multer.File,
|
||||
aliases: Record<string, string>,
|
||||
): Promise<Record<string, string>[]> {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('فایل اکسل خالی است');
|
||||
}
|
||||
|
||||
const wb = new ExcelJS.Workbook();
|
||||
// exceljs accepts Node Buffer
|
||||
await wb.xlsx.load(Uint8Array.from(file.buffer) as unknown as ExcelJS.Buffer);
|
||||
const sheet = wb.worksheets[0];
|
||||
if (!sheet) throw new BadRequestException('شیت اکسل پیدا نشد');
|
||||
|
||||
const headerRow = sheet.getRow(1);
|
||||
const map = new Map<number, string>();
|
||||
headerRow.eachCell((cell, col) => {
|
||||
const raw = cellStr(cell.value);
|
||||
if (!raw) return;
|
||||
const key =
|
||||
aliases[normalizeHeader(raw)] ??
|
||||
aliases[raw.trim()] ??
|
||||
aliases[normalizeHeader(raw.replace(//g, ''))];
|
||||
if (key) map.set(col, key);
|
||||
});
|
||||
|
||||
if (!map.size) {
|
||||
throw new BadRequestException(
|
||||
'ستونهای فایل شناسایی نشد. از قالب خروجی همین سیستم استفاده کنید.',
|
||||
);
|
||||
}
|
||||
|
||||
const rows: Record<string, string>[] = [];
|
||||
sheet.eachRow((row, rowNumber) => {
|
||||
if (rowNumber === 1) return;
|
||||
const item: Record<string, string> = {};
|
||||
let any = false;
|
||||
for (const [col, key] of map) {
|
||||
const value = cellStr(row.getCell(col).value);
|
||||
if (value) any = true;
|
||||
item[key] = value;
|
||||
}
|
||||
if (any) rows.push(item);
|
||||
});
|
||||
|
||||
if (!rows.length) {
|
||||
throw new BadRequestException('هیچ ردیف دادهای در فایل نیست');
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { ROLE_PERMISSION_ITEMS, type RolePermissionKey } from '@mento/shared';
|
||||
import { ROLE_COLORS } from './role-defaults';
|
||||
|
||||
const PERMISSION_KEYS = ROLE_PERMISSION_ITEMS.map((item) => item.key);
|
||||
|
||||
export class CreateOrgRoleDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(80)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(280)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn([...ROLE_COLORS])
|
||||
color?: (typeof ROLE_COLORS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsIn(PERMISSION_KEYS, { each: true })
|
||||
permissions?: RolePermissionKey[];
|
||||
}
|
||||
|
||||
export class UpdateOrgRoleDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(80)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(280)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn([...ROLE_COLORS])
|
||||
color?: (typeof ROLE_COLORS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsIn(PERMISSION_KEYS, { each: true })
|
||||
permissions?: RolePermissionKey[];
|
||||
}
|
||||
|
||||
export class RolePermissionPatchItemDto {
|
||||
@IsString()
|
||||
id!: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsIn(PERMISSION_KEYS, { each: true })
|
||||
permissions!: RolePermissionKey[];
|
||||
}
|
||||
|
||||
export class BulkUpdatePermissionsDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RolePermissionPatchItemDto)
|
||||
items!: RolePermissionPatchItemDto[];
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import type { UserRole } from '@mento/shared';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { BulkUpdatePermissionsDto, CreateOrgRoleDto, UpdateOrgRoleDto } from './dto';
|
||||
import { OrgRolesService } from './org-roles.service';
|
||||
|
||||
@Controller('org-roles')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class OrgRolesController {
|
||||
constructor(private readonly orgRoles: OrgRolesService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: { organizationId: string }) {
|
||||
return this.orgRoles.list(user.organizationId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('owner', 'admin')
|
||||
create(
|
||||
@CurrentUser() user: { organizationId: string; role: UserRole },
|
||||
@Body() dto: CreateOrgRoleDto,
|
||||
) {
|
||||
return this.orgRoles.create(user.organizationId, user.role, dto);
|
||||
}
|
||||
|
||||
@Patch('permissions')
|
||||
@Roles('owner', 'admin')
|
||||
bulkPermissions(
|
||||
@CurrentUser() user: { organizationId: string; role: UserRole },
|
||||
@Body() dto: BulkUpdatePermissionsDto,
|
||||
) {
|
||||
return this.orgRoles.bulkUpdatePermissions(user.organizationId, user.role, dto.items);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles('owner', 'admin')
|
||||
update(
|
||||
@CurrentUser() user: { organizationId: string; role: UserRole },
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateOrgRoleDto,
|
||||
) {
|
||||
return this.orgRoles.update(user.organizationId, user.role, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('owner', 'admin')
|
||||
remove(
|
||||
@CurrentUser() user: { organizationId: string; role: UserRole },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.orgRoles.remove(user.organizationId, user.role, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { OrgRolesController } from './org-roles.controller';
|
||||
import { OrgRolesService } from './org-roles.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [OrgRolesController],
|
||||
providers: [OrgRolesService],
|
||||
exports: [OrgRolesService],
|
||||
})
|
||||
export class OrgRolesModule {}
|
||||
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { OrgRoleDto, RolePermissionKey, UserRole } from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
ALL_ROLE_PERMISSIONS,
|
||||
defaultColorForSystemKey,
|
||||
sanitizePermissions,
|
||||
SYSTEM_ROLE_SEEDS,
|
||||
systemKeyToUserRole,
|
||||
} from './role-defaults';
|
||||
|
||||
type RoleRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
color: string;
|
||||
permissions: string[];
|
||||
systemKey: string | null;
|
||||
users: Array<{
|
||||
id: string;
|
||||
fullName: string;
|
||||
avatarStorageKey: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class OrgRolesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(organizationId: string): Promise<OrgRoleDto[]> {
|
||||
await this.ensureDefaultRoles(organizationId);
|
||||
const rows = await this.prisma.orgRole.findMany({
|
||||
where: { organizationId },
|
||||
include: {
|
||||
users: {
|
||||
select: { id: true, fullName: true, avatarStorageKey: true },
|
||||
orderBy: { fullName: 'asc' },
|
||||
},
|
||||
},
|
||||
orderBy: [{ systemKey: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
return rows.map((row) => this.toDto(row));
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
actorRole: UserRole,
|
||||
dto: {
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
permissions?: RolePermissionKey[];
|
||||
},
|
||||
): Promise<OrgRoleDto> {
|
||||
this.assertManager(actorRole);
|
||||
const name = dto.name.trim();
|
||||
if (name.length < 2) {
|
||||
throw new BadRequestException('نام نقش باید حداقل دو حرف باشد');
|
||||
}
|
||||
try {
|
||||
const row = await this.prisma.orgRole.create({
|
||||
data: {
|
||||
organizationId,
|
||||
name,
|
||||
description: (dto.description ?? '').trim(),
|
||||
color: dto.color ?? 'teal',
|
||||
permissions: dto.permissions?.length ? dto.permissions : [...ALL_ROLE_PERMISSIONS],
|
||||
},
|
||||
include: {
|
||||
users: {
|
||||
select: { id: true, fullName: true, avatarStorageKey: true },
|
||||
orderBy: { fullName: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new ConflictException('نقشی با این نام از قبل وجود دارد');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
actorRole: UserRole,
|
||||
id: string,
|
||||
dto: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
permissions?: RolePermissionKey[];
|
||||
},
|
||||
): Promise<OrgRoleDto> {
|
||||
this.assertManager(actorRole);
|
||||
const existing = await this.prisma.orgRole.findFirst({
|
||||
where: { id, organizationId },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('نقش پیدا نشد');
|
||||
|
||||
const data: Prisma.OrgRoleUpdateInput = {};
|
||||
if (dto.name !== undefined) {
|
||||
const name = dto.name.trim();
|
||||
if (name.length < 2) {
|
||||
throw new BadRequestException('نام نقش باید حداقل دو حرف باشد');
|
||||
}
|
||||
data.name = name;
|
||||
}
|
||||
if (dto.description !== undefined) data.description = dto.description.trim();
|
||||
if (dto.color !== undefined) data.color = dto.color;
|
||||
if (dto.permissions !== undefined) data.permissions = sanitizePermissions(dto.permissions);
|
||||
|
||||
try {
|
||||
const row = await this.prisma.orgRole.update({
|
||||
where: { id: existing.id },
|
||||
data,
|
||||
include: {
|
||||
users: {
|
||||
select: { id: true, fullName: true, avatarStorageKey: true },
|
||||
orderBy: { fullName: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new ConflictException('نقشی با این نام از قبل وجود دارد');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async bulkUpdatePermissions(
|
||||
organizationId: string,
|
||||
actorRole: UserRole,
|
||||
items: Array<{ id: string; permissions: RolePermissionKey[] }>,
|
||||
): Promise<OrgRoleDto[]> {
|
||||
this.assertManager(actorRole);
|
||||
await this.ensureDefaultRoles(organizationId);
|
||||
await this.prisma.$transaction(
|
||||
items.map((item) =>
|
||||
this.prisma.orgRole.updateMany({
|
||||
where: { id: item.id, organizationId },
|
||||
data: { permissions: sanitizePermissions(item.permissions) },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return this.list(organizationId);
|
||||
}
|
||||
|
||||
async remove(organizationId: string, actorRole: UserRole, id: string): Promise<{ ok: true }> {
|
||||
this.assertManager(actorRole);
|
||||
const existing = await this.prisma.orgRole.findFirst({
|
||||
where: { id, organizationId },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('نقش پیدا نشد');
|
||||
if (existing.systemKey) {
|
||||
throw new BadRequestException('نقشهای سیستمی قابل حذف نیستند');
|
||||
}
|
||||
await this.prisma.orgRole.delete({ where: { id: existing.id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async ensureDefaultRoles(organizationId: string) {
|
||||
const existing = await this.prisma.orgRole.findMany({
|
||||
where: { organizationId },
|
||||
});
|
||||
const byKey = new Map(existing.filter((r) => r.systemKey).map((r) => [r.systemKey!, r]));
|
||||
for (const seed of SYSTEM_ROLE_SEEDS) {
|
||||
if (byKey.has(seed.systemKey)) continue;
|
||||
const created = await this.prisma.orgRole.create({
|
||||
data: {
|
||||
organizationId,
|
||||
name: seed.name,
|
||||
description: seed.description,
|
||||
color: seed.color,
|
||||
permissions: seed.permissions,
|
||||
systemKey: seed.systemKey,
|
||||
},
|
||||
});
|
||||
byKey.set(seed.systemKey, created);
|
||||
}
|
||||
|
||||
const unassigned = await this.prisma.user.findMany({
|
||||
where: { organizationId, orgRoleId: null },
|
||||
select: { id: true, role: true },
|
||||
});
|
||||
for (const user of unassigned) {
|
||||
const role = byKey.get(user.role);
|
||||
if (!role) continue;
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { orgRoleId: role.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async resolveInviteRole(
|
||||
organizationId: string,
|
||||
dto: { orgRoleId?: string; role?: Exclude<UserRole, 'owner'> },
|
||||
): Promise<{ orgRoleId: string; role: UserRole }> {
|
||||
await this.ensureDefaultRoles(organizationId);
|
||||
if (dto.orgRoleId) {
|
||||
const orgRole = await this.prisma.orgRole.findFirst({
|
||||
where: { id: dto.orgRoleId, organizationId },
|
||||
});
|
||||
if (!orgRole) throw new BadRequestException('نقش انتخابشده معتبر نیست');
|
||||
return {
|
||||
orgRoleId: orgRole.id,
|
||||
role: systemKeyToUserRole(orgRole.systemKey),
|
||||
};
|
||||
}
|
||||
const role = dto.role ?? 'sales';
|
||||
const orgRole = await this.prisma.orgRole.findFirst({
|
||||
where: { organizationId, systemKey: role },
|
||||
});
|
||||
if (!orgRole) {
|
||||
throw new BadRequestException('نقش پیشفرض پیدا نشد');
|
||||
}
|
||||
return { orgRoleId: orgRole.id, role };
|
||||
}
|
||||
|
||||
defaultColorFor(role: UserRole) {
|
||||
return defaultColorForSystemKey(role);
|
||||
}
|
||||
|
||||
private assertManager(actorRole: UserRole) {
|
||||
if (actorRole !== 'owner' && actorRole !== 'admin') {
|
||||
throw new ForbiddenException('فقط مالک یا مدیر میتواند نقشها را مدیریت کند');
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: RoleRow): OrgRoleDto {
|
||||
const systemKey =
|
||||
row.systemKey === 'owner' ||
|
||||
row.systemKey === 'admin' ||
|
||||
row.systemKey === 'sales' ||
|
||||
row.systemKey === 'support'
|
||||
? row.systemKey
|
||||
: null;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
color: row.color,
|
||||
permissions: sanitizePermissions(row.permissions),
|
||||
systemKey,
|
||||
userCount: row.users.length,
|
||||
members: row.users.map((user) => ({
|
||||
id: user.id,
|
||||
fullName: user.fullName,
|
||||
hasAvatar: Boolean(user.avatarStorageKey),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { RolePermissionKey, UserRole } from '@mento/shared';
|
||||
import { ROLE_PERMISSION_ITEMS } from '@mento/shared';
|
||||
|
||||
export const ALL_ROLE_PERMISSIONS: RolePermissionKey[] = ROLE_PERMISSION_ITEMS.map(
|
||||
(item) => item.key,
|
||||
);
|
||||
|
||||
export const ROLE_COLORS = ['teal', 'violet', 'rose', 'sky', 'amber', 'slate', 'pink'] as const;
|
||||
export type RoleColor = (typeof ROLE_COLORS)[number];
|
||||
|
||||
export function defaultColorForSystemKey(key: UserRole): RoleColor {
|
||||
if (key === 'owner') return 'violet';
|
||||
if (key === 'admin') return 'rose';
|
||||
if (key === 'support') return 'sky';
|
||||
return 'teal';
|
||||
}
|
||||
|
||||
export const SYSTEM_ROLE_SEEDS: Array<{
|
||||
systemKey: UserRole;
|
||||
name: string;
|
||||
description: string;
|
||||
color: RoleColor;
|
||||
permissions: RolePermissionKey[];
|
||||
}> = [
|
||||
{
|
||||
systemKey: 'owner',
|
||||
name: 'مدیر سیستم',
|
||||
description: 'دسترسی کامل به تمام بخشهای سامانه.',
|
||||
color: 'violet',
|
||||
permissions: [...ALL_ROLE_PERMISSIONS],
|
||||
},
|
||||
{
|
||||
systemKey: 'admin',
|
||||
name: 'مدیر',
|
||||
description: 'مدیریت کاربران، تنظیمات و همه بخشهای سازمان.',
|
||||
color: 'rose',
|
||||
permissions: [...ALL_ROLE_PERMISSIONS],
|
||||
},
|
||||
{
|
||||
systemKey: 'sales',
|
||||
name: 'کارشناس فروش',
|
||||
description: 'امکان دسترسی به بخشهای فروش و مشتریان برای انجام فعالیتهای روزمره.',
|
||||
color: 'teal',
|
||||
permissions: [...ALL_ROLE_PERMISSIONS],
|
||||
},
|
||||
{
|
||||
systemKey: 'support',
|
||||
name: 'کارشناس پشتیبانی',
|
||||
description: 'پاسخگویی به مشتریان و پیگیری تیکتها و فعالیتها.',
|
||||
color: 'sky',
|
||||
permissions: ['customers', 'activities', 'notes', 'reports'],
|
||||
},
|
||||
];
|
||||
|
||||
export function isRolePermissionKey(value: string): value is RolePermissionKey {
|
||||
return ALL_ROLE_PERMISSIONS.includes(value as RolePermissionKey);
|
||||
}
|
||||
|
||||
export function sanitizePermissions(input: unknown): RolePermissionKey[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
return [...new Set(input.filter((item): item is RolePermissionKey => isRolePermissionKey(String(item))))];
|
||||
}
|
||||
|
||||
export function systemKeyToUserRole(key: string | null | undefined): UserRole {
|
||||
if (key === 'owner' || key === 'admin' || key === 'sales' || key === 'support') {
|
||||
return key;
|
||||
}
|
||||
return 'sales';
|
||||
}
|
||||
@@ -7,12 +7,20 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import {
|
||||
CreateProjectBoardDto,
|
||||
CreateProjectCardDto,
|
||||
@@ -20,22 +28,25 @@ import {
|
||||
UpdateProjectCardDto,
|
||||
} from './dto';
|
||||
import { ProjectBoardsService } from './project-boards.service';
|
||||
|
||||
type AuthUser = { organizationId: string; userId: string; role: string };
|
||||
import { ProjectCardsExcelService } from './project-cards-excel.service';
|
||||
import { brand } from '@mento/shared';
|
||||
|
||||
@Controller()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class ProjectBoardsController {
|
||||
constructor(private readonly boards: ProjectBoardsService) {}
|
||||
constructor(
|
||||
private readonly boards: ProjectBoardsService,
|
||||
private readonly excelService: ProjectCardsExcelService,
|
||||
) {}
|
||||
|
||||
@Get('project-boards')
|
||||
listBoards(@CurrentUser() user: AuthUser) {
|
||||
listBoards(@CurrentUser() user: Actor) {
|
||||
return this.boards.list(user.organizationId);
|
||||
}
|
||||
|
||||
@Get('project-boards/board')
|
||||
board(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('boardId') boardId?: string,
|
||||
) {
|
||||
return this.boards.getBoard(user.organizationId, boardId, user);
|
||||
@@ -44,20 +55,48 @@ export class ProjectBoardsController {
|
||||
@Post('project-boards')
|
||||
@Roles('owner', 'admin')
|
||||
createBoard(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@CurrentUser() user: Actor,
|
||||
@Body() dto: CreateProjectBoardDto,
|
||||
) {
|
||||
return this.boards.create(user.organizationId, dto.name);
|
||||
}
|
||||
|
||||
@Get('project-cards/export')
|
||||
async exportCards(
|
||||
@CurrentUser() user: Actor,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const buffer = await this.excelService.export(user);
|
||||
res.set({
|
||||
'Content-Type':
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition': `attachment; filename="${brand.filePrefix}-project-cards.xlsx"`,
|
||||
});
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
|
||||
@Post('project-cards/import')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 15_000_000 },
|
||||
}),
|
||||
)
|
||||
importCards(
|
||||
@CurrentUser() user: Actor,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.excelService.import(user, file);
|
||||
}
|
||||
|
||||
@Get('project-cards/:id')
|
||||
getCard(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
getCard(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.boards.getCard(user.organizationId, id, user);
|
||||
}
|
||||
|
||||
@Post('project-cards')
|
||||
createCard(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@CurrentUser() user: Actor,
|
||||
@Body() dto: CreateProjectCardDto,
|
||||
) {
|
||||
return this.boards.createCard(user.organizationId, user, dto);
|
||||
@@ -65,7 +104,7 @@ export class ProjectBoardsController {
|
||||
|
||||
@Patch('project-cards/:id')
|
||||
updateCard(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateProjectCardDto,
|
||||
) {
|
||||
@@ -74,7 +113,7 @@ export class ProjectBoardsController {
|
||||
|
||||
@Patch('project-cards/:id/move')
|
||||
moveCard(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: MoveProjectCardDto,
|
||||
) {
|
||||
@@ -82,7 +121,7 @@ export class ProjectBoardsController {
|
||||
}
|
||||
|
||||
@Delete('project-cards/:id')
|
||||
async removeCard(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
async removeCard(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
await this.boards.removeCard(user.organizationId, id, user);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { ProjectBoardsController } from './project-boards.controller';
|
||||
import { ProjectBoardsService } from './project-boards.service';
|
||||
import { ProjectCardsExcelService } from './project-cards-excel.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [ProjectBoardsController],
|
||||
providers: [ProjectBoardsService],
|
||||
providers: [ProjectBoardsService, ProjectCardsExcelService],
|
||||
exports: [ProjectBoardsService],
|
||||
})
|
||||
export class ProjectBoardsModule {}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { ImportContactsResult } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { ownerWhere } from '../common/record-scope';
|
||||
import { normalizePhone } from '../contacts/normalize';
|
||||
import {
|
||||
formatJalaliDate,
|
||||
parseJalaliDateTime,
|
||||
readMappedRows,
|
||||
stripHtml,
|
||||
writeXlsx,
|
||||
} from '../common/excel';
|
||||
import { ProjectBoardsService } from './project-boards.service';
|
||||
|
||||
const CASE_COLUMNS = [
|
||||
{ header: '_type', key: '_type', width: 12 },
|
||||
{ header: 'کد دیدار کارت', key: 'didarId', width: 14 },
|
||||
{ header: 'عنوان کارت', key: 'title', width: 28 },
|
||||
{ header: 'توضیحات کارت', key: 'notes', width: 32 },
|
||||
{ header: 'وضعیت کارت', key: 'status', width: 12 },
|
||||
{ header: 'مسئول کارت', key: 'ownerName', width: 16 },
|
||||
{ header: 'مجوز مشاهده کارت', key: 'visibility', width: 18 },
|
||||
{ header: 'تاریخ ثبت کارت', key: 'createdAt', width: 16 },
|
||||
{ header: 'نام خانوادگی شخص کارت', key: 'personName', width: 22 },
|
||||
{ header: 'کد دیدار شخص', key: 'personCode', width: 14 },
|
||||
{ header: 'موبایل شخص کارت', key: 'personMobile', width: 16 },
|
||||
{ header: 'کاریز کارت', key: 'boardName', width: 20 },
|
||||
{ header: 'مرحله کاریز کارت', key: 'stageName', width: 18 },
|
||||
{ header: 'تاریخ انجام کارت', key: 'doneDate', width: 16 },
|
||||
{ header: 'برچسب', key: 'tag', width: 16 },
|
||||
];
|
||||
|
||||
const CASE_ALIASES: Record<string, string> = {
|
||||
_type: '_type',
|
||||
type: '_type',
|
||||
کددیدارکارت: 'didarId',
|
||||
عنوانکارت: 'title',
|
||||
عنوان: 'title',
|
||||
توضیحاتکارت: 'notes',
|
||||
وضعیتکارت: 'status',
|
||||
مسئولکارت: 'ownerName',
|
||||
مجوزمشاهدهکارت: 'visibility',
|
||||
تاریخثبتکارت: 'createdAt',
|
||||
نامخانوادگیشخصکارت: 'personName',
|
||||
کددیدارشخص: 'personCode',
|
||||
موبایلشخصکارت: 'personMobile',
|
||||
کاریزکارت: 'boardName',
|
||||
مرحلهکاریزکارت: 'stageName',
|
||||
تاریخانجامکارت: 'doneDate',
|
||||
برچسب: 'tag',
|
||||
};
|
||||
|
||||
function isClosedStatus(raw?: string) {
|
||||
return /بسته|انجام|done|closed/i.test(raw ?? '');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ProjectCardsExcelService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly boards: ProjectBoardsService,
|
||||
) {}
|
||||
|
||||
async export(actor: Actor): Promise<Buffer> {
|
||||
const rows = await this.prisma.projectCard.findMany({
|
||||
where: { organizationId: actor.organizationId, ...ownerWhere(actor) },
|
||||
include: {
|
||||
person: {
|
||||
select: { id: true, firstName: true, lastName: true, mobile: true },
|
||||
},
|
||||
owner: { select: { fullName: true } },
|
||||
board: { select: { name: true } },
|
||||
stage: { select: { name: true } },
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
take: 10000,
|
||||
});
|
||||
|
||||
return writeXlsx(
|
||||
'Case',
|
||||
CASE_COLUMNS,
|
||||
rows.map((row) => {
|
||||
const closed = /انجام|بسته|done/i.test(row.stage.name);
|
||||
return {
|
||||
_type: 'Case',
|
||||
didarId: row.id,
|
||||
title: row.title,
|
||||
notes: row.notes ?? '',
|
||||
status: closed ? 'بسته' : 'باز',
|
||||
ownerName: row.owner?.fullName ?? '',
|
||||
visibility: 'همه افراد شرکت',
|
||||
createdAt: formatJalaliDate(row.createdAt),
|
||||
personName: row.person
|
||||
? `${row.person.firstName} ${row.person.lastName}`.trim()
|
||||
: '',
|
||||
personCode: row.person?.id ?? '',
|
||||
personMobile: row.person?.mobile ?? '',
|
||||
boardName: row.board.name,
|
||||
stageName: row.stage.name,
|
||||
doneDate: closed ? formatJalaliDate(row.updatedAt) : '',
|
||||
tag: '',
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async import(actor: Actor, file: Express.Multer.File): Promise<ImportContactsResult> {
|
||||
const rows = await readMappedRows(file, CASE_ALIASES);
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
const rowNum = i + 2;
|
||||
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'case') {
|
||||
continue;
|
||||
}
|
||||
const title = (row.title || '').trim();
|
||||
if (title.length < 2) {
|
||||
result.errors.push({ row: rowNum, message: 'عنوان کارت الزامی است' });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const { boardId, stageId } = await this.resolveBoardAndStage(
|
||||
actor.organizationId,
|
||||
row.boardName,
|
||||
row.stageName,
|
||||
isClosedStatus(row.status),
|
||||
);
|
||||
const duplicate = await this.prisma.projectCard.findFirst({
|
||||
where: { organizationId: actor.organizationId, boardId, title },
|
||||
select: { id: true },
|
||||
});
|
||||
if (duplicate) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const personId = await this.findPersonId(actor.organizationId, row);
|
||||
const ownerId = await this.findUserId(actor.organizationId, row.ownerName);
|
||||
const notes = [stripHtml(row.notes ?? ''), row.tag, row.didarId ? `کد دیدار: ${row.didarId}` : '']
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
const doneAt = parseJalaliDateTime(row.doneDate);
|
||||
const notesWithDone = doneAt
|
||||
? `${notes}${notes ? '\n' : ''}تاریخ انجام: ${row.doneDate}`.trim()
|
||||
: notes;
|
||||
|
||||
await this.boards.createCard(actor.organizationId, actor, {
|
||||
title,
|
||||
boardId,
|
||||
stageId,
|
||||
notes: notesWithDone || undefined,
|
||||
personId,
|
||||
ownerId,
|
||||
});
|
||||
result.created += 1;
|
||||
} catch (e) {
|
||||
result.errors.push({
|
||||
row: rowNum,
|
||||
message: e instanceof Error ? e.message : 'خطای ناشناخته',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async resolveBoardAndStage(
|
||||
organizationId: string,
|
||||
boardName?: string,
|
||||
stageName?: string,
|
||||
closed = false,
|
||||
) {
|
||||
const name = boardName?.trim() || 'پروژه داخلی';
|
||||
const wanted = stageName?.trim();
|
||||
let board = await this.prisma.projectBoard.findFirst({
|
||||
where: { organizationId, name: { equals: name, mode: 'insensitive' } },
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
if (!board) {
|
||||
const stageCreates = wanted
|
||||
? [{ name: wanted, position: 0 }]
|
||||
: [
|
||||
{ name: 'صف', position: 0 },
|
||||
{ name: 'در حال انجام', position: 1 },
|
||||
{ name: 'بازبینی', position: 2 },
|
||||
{ name: 'انجامشده', position: 3 },
|
||||
];
|
||||
board = await this.prisma.projectBoard.create({
|
||||
data: {
|
||||
organizationId,
|
||||
name,
|
||||
isDefault: false,
|
||||
stages: { create: stageCreates },
|
||||
},
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
}
|
||||
|
||||
if (wanted) {
|
||||
const existing = board.stages.find(
|
||||
(s) => s.name.trim().toLowerCase() === wanted.toLowerCase(),
|
||||
);
|
||||
if (existing) return { boardId: board.id, stageId: existing.id };
|
||||
const maxPos = board.stages.reduce((m, s) => Math.max(m, s.position), -1);
|
||||
const stage = await this.prisma.projectBoardStage.create({
|
||||
data: { boardId: board.id, name: wanted, position: maxPos + 1 },
|
||||
});
|
||||
return { boardId: board.id, stageId: stage.id };
|
||||
}
|
||||
|
||||
const stage = closed
|
||||
? board.stages[board.stages.length - 1]
|
||||
: board.stages[0];
|
||||
if (!stage) throw new Error('برد مرحلهای ندارد');
|
||||
return { boardId: board.id, stageId: stage.id };
|
||||
}
|
||||
|
||||
private async findPersonId(organizationId: string, row: Record<string, string>) {
|
||||
const mobile = normalizePhone(row.personMobile);
|
||||
if (mobile) {
|
||||
const needle = mobile.replace(/^\+98|^98|^0/, '');
|
||||
const byMobile = await this.prisma.person.findFirst({
|
||||
where: { organizationId, mobile: { contains: needle } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (byMobile) return byMobile.id;
|
||||
}
|
||||
const name = (row.personName ?? '').trim();
|
||||
if (name) {
|
||||
const byName = await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [
|
||||
{ lastName: { contains: name, mode: 'insensitive' } },
|
||||
{ firstName: { contains: name.split(/\s+/)[0]!, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (byName) return byName.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async findUserId(organizationId: string, name?: string) {
|
||||
const n = name?.trim();
|
||||
if (!n) return null;
|
||||
const row = await this.prisma.user.findFirst({
|
||||
where: { organizationId, fullName: { contains: n, mode: 'insensitive' } },
|
||||
select: { id: true },
|
||||
});
|
||||
return row?.id ?? null;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
@@ -30,13 +32,29 @@ export class InviteUserDto {
|
||||
@MaxLength(72)
|
||||
password!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(INVITE_ROLES)
|
||||
role!: (typeof INVITE_ROLES)[number];
|
||||
role?: (typeof INVITE_ROLES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
orgRoleId?: string;
|
||||
}
|
||||
|
||||
export class UpdateUserRoleDto {
|
||||
@IsOptional()
|
||||
@IsIn(ALL_ROLES)
|
||||
role!: (typeof ALL_ROLES)[number];
|
||||
role?: (typeof ALL_ROLES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
orgRoleId?: string;
|
||||
}
|
||||
|
||||
export class UpdateUserStatusDto {
|
||||
@Type(() => Boolean)
|
||||
@IsBoolean()
|
||||
isActive!: boolean;
|
||||
}
|
||||
|
||||
export class UpdateUserCommissionDto {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
StreamableFile,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { UserRole } from '@mento/shared';
|
||||
@@ -12,7 +13,12 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { InviteUserDto, UpdateUserCommissionDto, UpdateUserRoleDto } from './dto';
|
||||
import {
|
||||
InviteUserDto,
|
||||
UpdateUserCommissionDto,
|
||||
UpdateUserRoleDto,
|
||||
UpdateUserStatusDto,
|
||||
} from './dto';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Controller('users')
|
||||
@@ -25,6 +31,15 @@ export class UsersController {
|
||||
return this.usersService.list(user.organizationId);
|
||||
}
|
||||
|
||||
@Get(':id/avatar')
|
||||
async avatar(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
const file = await this.usersService.openAvatar(user.organizationId, id);
|
||||
return new StreamableFile(file.stream, { type: file.mimeType });
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('owner', 'admin')
|
||||
invite(
|
||||
@@ -49,6 +64,21 @@ export class UsersController {
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@Roles('owner', 'admin')
|
||||
updateStatus(
|
||||
@CurrentUser() user: { organizationId: string; userId: string; role: UserRole },
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateUserStatusDto,
|
||||
) {
|
||||
return this.usersService.updateStatus(
|
||||
user.organizationId,
|
||||
{ userId: user.userId, role: user.role },
|
||||
id,
|
||||
dto.isActive,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':id/role')
|
||||
@Roles('owner', 'admin')
|
||||
updateRole(
|
||||
@@ -60,7 +90,7 @@ export class UsersController {
|
||||
user.organizationId,
|
||||
{ userId: user.userId, role: user.role },
|
||||
id,
|
||||
dto.role,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { OrgRolesModule } from '../org-roles/org-roles.module';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
imports: [AuthModule, OrgRolesModule],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
|
||||
@@ -10,19 +10,51 @@ import * as bcrypt from 'bcryptjs';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { normalizeMobile } from '../auth/auth.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { OrgRolesService } from '../org-roles/org-roles.service';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import * as path from 'path';
|
||||
|
||||
function inviteEmailFromMobile(mobile: string): string {
|
||||
return `${mobile}@users.mento.local`;
|
||||
}
|
||||
|
||||
const AVATAR_EXT: Record<string, string> = {
|
||||
jpeg: 'image/jpeg',
|
||||
jpg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
};
|
||||
|
||||
type UserRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
mobile: string | null;
|
||||
fullName: string;
|
||||
role: UserRole;
|
||||
commissionPercent: { toString(): string } | number | null;
|
||||
createdAt: Date;
|
||||
isActive: boolean;
|
||||
lastLoginAt: Date | null;
|
||||
orgRoleId: string | null;
|
||||
avatarStorageKey: string | null;
|
||||
orgRole: { id: string; name: string; color: string } | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly orgRoles: OrgRolesService,
|
||||
private readonly files: LocalFileStorage,
|
||||
) {}
|
||||
|
||||
async list(organizationId: string): Promise<OrgUserDto[]> {
|
||||
await this.orgRoles.ensureDefaultRoles(organizationId);
|
||||
const rows = await this.prisma.user.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: [{ role: 'asc' }, { fullName: 'asc' }],
|
||||
include: { orgRole: { select: { id: true, name: true, color: true } } },
|
||||
orderBy: [{ lastLoginAt: 'desc' }, { fullName: 'asc' }],
|
||||
});
|
||||
return rows.map((u) => this.toDto(u));
|
||||
}
|
||||
@@ -54,6 +86,14 @@ export class UsersService {
|
||||
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
|
||||
}
|
||||
|
||||
const resolved = await this.orgRoles.resolveInviteRole(organizationId, {
|
||||
orgRoleId: dto.orgRoleId,
|
||||
role: dto.role,
|
||||
});
|
||||
if (resolved.role === 'owner' && actorRole !== 'owner') {
|
||||
throw new ForbiddenException('فقط مالک میتواند نقش مدیر سیستم را واگذار کند');
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
try {
|
||||
const row = await this.prisma.user.create({
|
||||
@@ -63,8 +103,11 @@ export class UsersService {
|
||||
mobile,
|
||||
fullName: dto.fullName.trim(),
|
||||
passwordHash,
|
||||
role: dto.role,
|
||||
role: resolved.role,
|
||||
orgRoleId: resolved.orgRoleId,
|
||||
isActive: true,
|
||||
},
|
||||
include: { orgRole: { select: { id: true, name: true, color: true } } },
|
||||
});
|
||||
return this.toDto(row);
|
||||
} catch (e) {
|
||||
@@ -94,6 +137,7 @@ export class UsersService {
|
||||
const row = await this.prisma.user.update({
|
||||
where: { id: target.id },
|
||||
data: { commissionPercent: new Prisma.Decimal(percent) },
|
||||
include: { orgRole: { select: { id: true, name: true, color: true } } },
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
@@ -102,7 +146,7 @@ export class UsersService {
|
||||
organizationId: string,
|
||||
actor: { userId: string; role: UserRole },
|
||||
targetId: string,
|
||||
nextRole: UserRole,
|
||||
dto: { role?: UserRole; orgRoleId?: string },
|
||||
): Promise<OrgUserDto> {
|
||||
if (actor.role !== 'owner' && actor.role !== 'admin') {
|
||||
throw new ForbiddenException('فقط مالک یا مدیر میتواند نقش را تغییر دهد');
|
||||
@@ -113,6 +157,28 @@ export class UsersService {
|
||||
});
|
||||
if (!target) throw new NotFoundException('کاربر پیدا نشد');
|
||||
|
||||
let nextRole = dto.role ?? target.role;
|
||||
let nextOrgRoleId = dto.orgRoleId ?? target.orgRoleId;
|
||||
|
||||
if (dto.orgRoleId) {
|
||||
const orgRole = await this.prisma.orgRole.findFirst({
|
||||
where: { id: dto.orgRoleId, organizationId },
|
||||
});
|
||||
if (!orgRole) throw new BadRequestException('نقش انتخابشده معتبر نیست');
|
||||
nextOrgRoleId = orgRole.id;
|
||||
nextRole = orgRole.systemKey
|
||||
? (['owner', 'admin', 'sales', 'support'].includes(orgRole.systemKey)
|
||||
? (orgRole.systemKey as UserRole)
|
||||
: 'sales')
|
||||
: 'sales';
|
||||
} else if (dto.role) {
|
||||
await this.orgRoles.ensureDefaultRoles(organizationId);
|
||||
const orgRole = await this.prisma.orgRole.findFirst({
|
||||
where: { organizationId, systemKey: dto.role },
|
||||
});
|
||||
nextOrgRoleId = orgRole?.id ?? nextOrgRoleId;
|
||||
}
|
||||
|
||||
if (actor.role === 'admin') {
|
||||
if (target.role === 'owner' || nextRole === 'owner') {
|
||||
throw new ForbiddenException('مدیر نمیتواند نقش مالک را تغییر دهد');
|
||||
@@ -132,26 +198,76 @@ export class UsersService {
|
||||
|
||||
const row = await this.prisma.user.update({
|
||||
where: { id: target.id },
|
||||
data: { role: nextRole },
|
||||
data: { role: nextRole, orgRoleId: nextOrgRoleId },
|
||||
include: { orgRole: { select: { id: true, name: true, color: true } } },
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
private toDto(row: {
|
||||
id: string;
|
||||
email: string;
|
||||
mobile?: string | null;
|
||||
fullName: string;
|
||||
role: UserRole;
|
||||
commissionPercent?: { toString(): string } | number;
|
||||
createdAt: Date;
|
||||
}): OrgUserDto {
|
||||
async updateStatus(
|
||||
organizationId: string,
|
||||
actor: { userId: string; role: UserRole },
|
||||
targetId: string,
|
||||
isActive: boolean,
|
||||
): Promise<OrgUserDto> {
|
||||
if (actor.role !== 'owner' && actor.role !== 'admin') {
|
||||
throw new ForbiddenException('فقط مالک یا مدیر میتواند وضعیت کاربر را تغییر دهد');
|
||||
}
|
||||
if (actor.userId === targetId && !isActive) {
|
||||
throw new BadRequestException('نمیتوانید حساب خودتان را غیرفعال کنید');
|
||||
}
|
||||
const target = await this.prisma.user.findFirst({
|
||||
where: { id: targetId, organizationId },
|
||||
});
|
||||
if (!target) throw new NotFoundException('کاربر پیدا نشد');
|
||||
if (target.role === 'owner' && !isActive && actor.role !== 'owner') {
|
||||
throw new ForbiddenException('مدیر نمیتواند مالک را غیرفعال کند');
|
||||
}
|
||||
if (target.role === 'owner' && !isActive) {
|
||||
const activeOwners = await this.prisma.user.count({
|
||||
where: { organizationId, role: 'owner', isActive: true, id: { not: targetId } },
|
||||
});
|
||||
if (activeOwners < 1) {
|
||||
throw new BadRequestException('حداقل یک مالک فعال لازم است');
|
||||
}
|
||||
}
|
||||
const row = await this.prisma.user.update({
|
||||
where: { id: target.id },
|
||||
data: { isActive },
|
||||
include: { orgRole: { select: { id: true, name: true, color: true } } },
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async openAvatar(organizationId: string, userId: string) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: userId, organizationId },
|
||||
select: { id: true, avatarStorageKey: true },
|
||||
});
|
||||
if (!user?.avatarStorageKey) {
|
||||
throw new NotFoundException('عکس پروفایل تنظیم نشده');
|
||||
}
|
||||
const ext = path.extname(user.avatarStorageKey).slice(1).toLowerCase();
|
||||
const mime = AVATAR_EXT[ext] ?? 'image/jpeg';
|
||||
return {
|
||||
stream: this.files.openReadStream(user.avatarStorageKey),
|
||||
mimeType: mime,
|
||||
};
|
||||
}
|
||||
|
||||
private toDto(row: UserRow): OrgUserDto {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
mobile: row.mobile ?? null,
|
||||
fullName: row.fullName,
|
||||
role: row.role,
|
||||
orgRoleId: row.orgRoleId,
|
||||
orgRoleName: row.orgRole?.name,
|
||||
orgRoleColor: row.orgRole?.color ?? this.orgRoles.defaultColorFor(row.role),
|
||||
isActive: row.isActive,
|
||||
lastLoginAt: row.lastLoginAt ? row.lastLoginAt.toISOString() : null,
|
||||
hasAvatar: Boolean(row.avatarStorageKey),
|
||||
commissionPercent: Number(row.commissionPercent ?? 0),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
|
||||
@@ -18,8 +18,10 @@ import type {
|
||||
ActivityDto,
|
||||
ActivityFilter,
|
||||
ActivityType,
|
||||
ImportContactsResult,
|
||||
PaginatedResponse,
|
||||
} from '@mento/shared';
|
||||
import { brand } from '@mento/shared';
|
||||
|
||||
type TypeFilter = 'all' | ActivityType;
|
||||
type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled';
|
||||
@@ -70,6 +72,9 @@ const activityModal = ref<{
|
||||
title?: string;
|
||||
} | null>(null);
|
||||
const menuId = ref<string | null>(null);
|
||||
const busy = ref(false);
|
||||
const importMsg = ref('');
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const counts = ref<Record<ActivityFilter, number>>({
|
||||
today: 0,
|
||||
overdue: 0,
|
||||
@@ -260,6 +265,65 @@ function closePopovers() {
|
||||
menuId.value = null;
|
||||
}
|
||||
|
||||
async function exportExcel() {
|
||||
busy.value = true;
|
||||
importMsg.value = '';
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.get<Blob>('/activities/export', { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(data);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${brand.filePrefix}-activities.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
error.value = 'خروجی اکسل ناموفق بود.';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pickImportFile() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
async function onImportFile(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
busy.value = true;
|
||||
importMsg.value = '';
|
||||
error.value = '';
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const { data } = await api.post<ImportContactsResult>('/activities/import', form, {
|
||||
headers: { 'Content-Type': undefined },
|
||||
});
|
||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||
}`;
|
||||
if (data.errors.length) {
|
||||
error.value = data.errors
|
||||
.slice(0, 3)
|
||||
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||
.join(' | ');
|
||||
}
|
||||
await Promise.all([load(), loadCounts()]);
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ایمپورت ناموفق بود');
|
||||
} else {
|
||||
error.value = 'ایمپورت ناموفق بود';
|
||||
}
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.query.filter, route.query.q, route.query.page],
|
||||
() => {
|
||||
@@ -289,6 +353,21 @@ onMounted(() => {
|
||||
<p class="lead">تماس، جلسه و وظیفه — امروز و عقبافتادهها یکجا.</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<button class="btn ghost excel-btn" type="button" :disabled="busy" @click.stop="exportExcel">
|
||||
<span class="btn-full">خروجی اکسل</span>
|
||||
<span class="btn-short">خروجی</span>
|
||||
</button>
|
||||
<button class="btn ghost excel-btn" type="button" :disabled="busy" @click.stop="pickImportFile">
|
||||
<span class="btn-full">ورود از اکسل</span>
|
||||
<span class="btn-short">ورود</span>
|
||||
</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
hidden
|
||||
@change="onImportFile"
|
||||
/>
|
||||
<UiButton type="button" @click="activityModal = {}">
|
||||
<UiIcon name="plus" :size="16" />
|
||||
فعالیت جدید
|
||||
@@ -345,7 +424,8 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
|
||||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
|
||||
<div v-else-if="listEmpty" class="empty-card">
|
||||
<div class="art" aria-hidden="true">
|
||||
@@ -632,11 +712,24 @@ h1 {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.head-actions .excel-btn .btn-full {
|
||||
display: inline;
|
||||
}
|
||||
.head-actions .excel-btn .btn-short {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.head-actions,
|
||||
.tools {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@@ -1172,6 +1265,13 @@ time.note {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.head-actions .excel-btn .btn-full {
|
||||
display: none;
|
||||
}
|
||||
.head-actions .excel-btn .btn-short {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.55rem;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import type {
|
||||
ImportContactsResult,
|
||||
ProjectBoardBoardDto,
|
||||
ProjectBoardDto,
|
||||
ProjectCardDto,
|
||||
} from '@mento/shared';
|
||||
import { brand } from '@mento/shared';
|
||||
import { canManageSettings } from '@/lib/roles';
|
||||
|
||||
const route = useRoute();
|
||||
@@ -25,6 +27,9 @@ const draggingId = ref<string | null>(null);
|
||||
const dropStageId = ref<string | null>(null);
|
||||
const showCreate = ref(false);
|
||||
const newName = ref('');
|
||||
const busy = ref(false);
|
||||
const importMsg = ref('');
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const selectedBoardId = computed(() => {
|
||||
const q = route.query.boardId;
|
||||
@@ -132,6 +137,65 @@ function openCard(id: string) {
|
||||
void router.push({ name: 'project-card-detail', params: { id } });
|
||||
}
|
||||
|
||||
async function exportExcel() {
|
||||
busy.value = true;
|
||||
importMsg.value = '';
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.get<Blob>('/project-cards/export', { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(data);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${brand.filePrefix}-project-cards.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
error.value = 'خروجی اکسل ناموفق بود.';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pickImportFile() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
async function onImportFile(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
busy.value = true;
|
||||
importMsg.value = '';
|
||||
error.value = '';
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const { data } = await api.post<ImportContactsResult>('/project-cards/import', form, {
|
||||
headers: { 'Content-Type': undefined },
|
||||
});
|
||||
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
||||
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
||||
}`;
|
||||
if (data.errors.length) {
|
||||
error.value = data.errors
|
||||
.slice(0, 3)
|
||||
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
||||
.join(' | ');
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.boardId,
|
||||
(id, prev) => {
|
||||
@@ -171,6 +235,19 @@ onMounted(load);
|
||||
</label>
|
||||
|
||||
<div class="toolbar-actions">
|
||||
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="exportExcel">
|
||||
خروجی اکسل
|
||||
</button>
|
||||
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="pickImportFile">
|
||||
ورود از اکسل
|
||||
</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
hidden
|
||||
@change="onImportFile"
|
||||
/>
|
||||
<button class="btn-ghost-sm" type="button" :disabled="loading" @click="load">
|
||||
بروزرسانی
|
||||
</button>
|
||||
@@ -215,7 +292,8 @@ onMounted(load);
|
||||
</form>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading && !board" class="muted">در حال بارگذاری…</p>
|
||||
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
|
||||
<p v-if="loading && !board" class="muted">در حال بارگذاری…</p>
|
||||
|
||||
<div v-if="board" class="board">
|
||||
<div
|
||||
@@ -411,6 +489,12 @@ h1 {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.ok {
|
||||
margin: 0;
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.board {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
|
||||
+1574
-269
File diff suppressed because it is too large
Load Diff
@@ -44,23 +44,88 @@ export interface OrgUserDto {
|
||||
mobile?: string | null;
|
||||
fullName: string;
|
||||
role: UserRole;
|
||||
orgRoleId?: string | null;
|
||||
orgRoleName?: string;
|
||||
orgRoleColor?: string;
|
||||
isActive?: boolean;
|
||||
lastLoginAt?: string | null;
|
||||
hasAvatar?: boolean;
|
||||
commissionPercent?: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserCommissionRequest {
|
||||
commissionPercent: number;
|
||||
export type RolePermissionKey =
|
||||
| "customers"
|
||||
| "sales"
|
||||
| "billing"
|
||||
| "products"
|
||||
| "activities"
|
||||
| "notes"
|
||||
| "reports";
|
||||
|
||||
export const ROLE_PERMISSION_ITEMS: Array<{
|
||||
key: RolePermissionKey;
|
||||
label: string;
|
||||
}> = [
|
||||
{ key: "customers", label: "مشتریان" },
|
||||
{ key: "sales", label: "فروش و معاملات" },
|
||||
{ key: "billing", label: "پیشفاکتورها و فاکتورها" },
|
||||
{ key: "products", label: "محصولات و خدمات" },
|
||||
{ key: "activities", label: "فعالیتها" },
|
||||
{ key: "notes", label: "یادداشتها" },
|
||||
{ key: "reports", label: "گزارشها" },
|
||||
];
|
||||
|
||||
export interface OrgRoleMemberDto {
|
||||
id: string;
|
||||
fullName: string;
|
||||
hasAvatar: boolean;
|
||||
}
|
||||
|
||||
export interface OrgRoleDto {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
color: string;
|
||||
permissions: RolePermissionKey[];
|
||||
systemKey: UserRole | null;
|
||||
userCount: number;
|
||||
members: OrgRoleMemberDto[];
|
||||
}
|
||||
|
||||
export interface CreateOrgRoleRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
permissions?: RolePermissionKey[];
|
||||
}
|
||||
|
||||
export interface UpdateOrgRoleRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
permissions?: RolePermissionKey[];
|
||||
}
|
||||
|
||||
export interface InviteUserRequest {
|
||||
mobile: string;
|
||||
fullName: string;
|
||||
password: string;
|
||||
role: Exclude<UserRole, "owner">;
|
||||
role?: Exclude<UserRole, "owner">;
|
||||
orgRoleId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserRoleRequest {
|
||||
role: UserRole;
|
||||
role?: UserRole;
|
||||
orgRoleId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserStatusRequest {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateUserCommissionRequest {
|
||||
commissionPercent: number;
|
||||
}
|
||||
|
||||
export interface UpdateProfileRequest {
|
||||
|
||||
Reference in New Issue
Block a user