Add project board, loyalty club, and if/then automations.

Separate modules for post-sales kanban, person points/tiers, and deal-won/ticket-resolved rules without overlapping the sales pipeline.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 09:42:07 +03:30
co-authored by Cursor
parent 2e6c4779ab
commit c2bfa7749d
29 changed files with 3126 additions and 9 deletions
@@ -0,0 +1,112 @@
-- CreateEnum
CREATE TYPE "AutomationTrigger" AS ENUM ('deal_won', 'ticket_resolved');
-- CreateEnum
CREATE TYPE "AutomationAction" AS ENUM ('create_activity', 'create_notification', 'add_loyalty_points');
-- CreateTable
CREATE TABLE "ProjectBoard" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectBoard_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProjectBoardStage" (
"id" TEXT NOT NULL,
"boardId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"position" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectBoardStage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProjectCard" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"boardId" TEXT NOT NULL,
"stageId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"notes" TEXT,
"position" INTEGER NOT NULL DEFAULT 0,
"personId" TEXT,
"companyId" TEXT,
"dealId" TEXT,
"ownerId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectCard_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LoyaltyTier" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"minPoints" INTEGER NOT NULL DEFAULT 0,
"position" INTEGER NOT NULL DEFAULT 0,
"color" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LoyaltyTier_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AutomationRule" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"trigger" "AutomationTrigger" NOT NULL,
"action" "AutomationAction" NOT NULL,
"config" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AutomationRule_pkey" PRIMARY KEY ("id")
);
-- AlterTable
ALTER TABLE "Person" ADD COLUMN "loyaltyPoints" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Person" ADD COLUMN "loyaltyTierId" TEXT;
-- CreateIndex
CREATE INDEX "ProjectBoard_organizationId_idx" ON "ProjectBoard"("organizationId");
CREATE INDEX "ProjectBoard_organizationId_isDefault_idx" ON "ProjectBoard"("organizationId", "isDefault");
CREATE INDEX "ProjectBoardStage_boardId_position_idx" ON "ProjectBoardStage"("boardId", "position");
CREATE INDEX "ProjectCard_organizationId_idx" ON "ProjectCard"("organizationId");
CREATE INDEX "ProjectCard_boardId_stageId_idx" ON "ProjectCard"("boardId", "stageId");
CREATE INDEX "ProjectCard_stageId_position_idx" ON "ProjectCard"("stageId", "position");
CREATE INDEX "ProjectCard_personId_idx" ON "ProjectCard"("personId");
CREATE INDEX "ProjectCard_companyId_idx" ON "ProjectCard"("companyId");
CREATE INDEX "ProjectCard_dealId_idx" ON "ProjectCard"("dealId");
CREATE INDEX "ProjectCard_ownerId_idx" ON "ProjectCard"("ownerId");
CREATE INDEX "LoyaltyTier_organizationId_position_idx" ON "LoyaltyTier"("organizationId", "position");
CREATE INDEX "LoyaltyTier_organizationId_minPoints_idx" ON "LoyaltyTier"("organizationId", "minPoints");
CREATE INDEX "AutomationRule_organizationId_isActive_trigger_idx" ON "AutomationRule"("organizationId", "isActive", "trigger");
CREATE INDEX "Person_organizationId_loyaltyPoints_idx" ON "Person"("organizationId", "loyaltyPoints");
CREATE INDEX "Person_loyaltyTierId_idx" ON "Person"("loyaltyTierId");
-- AddForeignKey
ALTER TABLE "Person" ADD CONSTRAINT "Person_loyaltyTierId_fkey" FOREIGN KEY ("loyaltyTierId") REFERENCES "LoyaltyTier"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "ProjectBoard" ADD CONSTRAINT "ProjectBoard_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProjectBoardStage" ADD CONSTRAINT "ProjectBoardStage_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "ProjectBoard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProjectCard" ADD CONSTRAINT "ProjectCard_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProjectCard" ADD CONSTRAINT "ProjectCard_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "ProjectBoard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProjectCard" ADD CONSTRAINT "ProjectCard_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "ProjectBoardStage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "ProjectCard" ADD CONSTRAINT "ProjectCard_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "ProjectCard" ADD CONSTRAINT "ProjectCard_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "ProjectCard" ADD CONSTRAINT "ProjectCard_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "ProjectCard" ADD CONSTRAINT "ProjectCard_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "LoyaltyTier" ADD CONSTRAINT "LoyaltyTier_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AutomationRule" ADD CONSTRAINT "AutomationRule_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+116
View File
@@ -92,6 +92,17 @@ enum TicketPriority {
urgent
}
enum AutomationTrigger {
deal_won
ticket_resolved
}
enum AutomationAction {
create_activity
create_notification
add_loyalty_points
}
model Organization {
id String @id @default(cuid())
name String
@@ -119,6 +130,10 @@ model Organization {
tickets Ticket[]
faqArticles FaqArticle[]
portalOtps PortalOtp[]
projectBoards ProjectBoard[]
projectCards ProjectCard[]
loyaltyTiers LoyaltyTier[]
automationRules AutomationRule[]
}
model User {
@@ -134,6 +149,7 @@ model User {
ownedInvoices Invoice[] @relation("InvoiceOwner")
ownedPeople Person[] @relation("PersonOwner")
ownedCompanies Company[] @relation("CompanyOwner")
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
activities Activity[] @relation("ActivityAssignee")
notes Note[] @relation("NoteAuthor")
noteAttachments NoteAttachment[] @relation("NoteAttachmentUploader")
@@ -170,6 +186,7 @@ model Company {
ownerId String?
owner User? @relation("CompanyOwner", fields: [ownerId], references: [id], onDelete: SetNull)
tickets Ticket[]
projectCards ProjectCard[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -194,6 +211,9 @@ model Person {
position String?
notes String?
customFields Json @default("{}")
loyaltyPoints Int @default(0)
loyaltyTierId String?
loyaltyTier LoyaltyTier? @relation(fields: [loyaltyTierId], references: [id], onDelete: SetNull)
deals Deal[]
activities Activity[]
smartNotes Note[]
@@ -202,6 +222,7 @@ model Person {
ownerId String?
owner User? @relation("PersonOwner", fields: [ownerId], references: [id], onDelete: SetNull)
tickets Ticket[]
projectCards ProjectCard[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -209,8 +230,10 @@ model Person {
@@index([organizationId, email])
@@index([organizationId, mobile])
@@index([organizationId, lastName, firstName])
@@index([organizationId, loyaltyPoints])
@@index([companyId])
@@index([ownerId])
@@index([loyaltyTierId])
}
model Pipeline {
@@ -276,6 +299,7 @@ model Deal {
quotes Quote[]
invoices Invoice[]
tickets Ticket[]
projectCards ProjectCard[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -736,3 +760,95 @@ model PortalOtp {
@@index([organizationId, mobile, createdAt])
@@index([expiresAt])
}
/// برد کانبان برای کارهای داخلی / پس‌ازفروش (جدا از کاریز فروش)
model ProjectBoard {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
isDefault Boolean @default(false)
stages ProjectBoardStage[]
cards ProjectCard[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
@@index([organizationId, isDefault])
}
model ProjectBoardStage {
id String @id @default(cuid())
boardId String
board ProjectBoard @relation(fields: [boardId], references: [id], onDelete: Cascade)
name String
position Int
cards ProjectCard[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([boardId, position])
}
model ProjectCard {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
boardId String
board ProjectBoard @relation(fields: [boardId], references: [id], onDelete: Cascade)
stageId String
stage ProjectBoardStage @relation(fields: [stageId], references: [id], onDelete: Restrict)
title String
notes String?
position Int @default(0)
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
companyId String?
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
dealId String?
deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull)
ownerId String?
owner User? @relation("ProjectCardOwner", fields: [ownerId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
@@index([boardId, stageId])
@@index([stageId, position])
@@index([personId])
@@index([companyId])
@@index([dealId])
@@index([ownerId])
}
model LoyaltyTier {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
minPoints Int @default(0)
position Int @default(0)
color String?
people Person[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, position])
@@index([organizationId, minPoints])
}
model AutomationRule {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
isActive Boolean @default(true)
trigger AutomationTrigger
action AutomationAction
/// عنوان فعالیت/اعلان، assigneeId، notifyUserId، points، activityType
config Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, isActive, trigger])
}
+6
View File
@@ -21,6 +21,9 @@ import { TicketsModule } from './tickets/tickets.module';
import { FaqModule } from './faq/faq.module';
import { PortalModule } from './portal/portal.module';
import { ReportsModule } from './reports/reports.module';
import { ProjectBoardsModule } from './project-boards/project-boards.module';
import { LoyaltyModule } from './loyalty/loyalty.module';
import { AutomationsModule } from './automations/automations.module';
@Module({
imports: [
@@ -49,6 +52,9 @@ import { ReportsModule } from './reports/reports.module';
FaqModule,
PortalModule,
ReportsModule,
ProjectBoardsModule,
LoyaltyModule,
AutomationsModule,
],
})
export class AppModule {}
@@ -0,0 +1,54 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
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 { CreateAutomationRuleDto, UpdateAutomationRuleDto } from './dto';
import { AutomationsService } from './automations.service';
@Controller('automations')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('owner', 'admin')
export class AutomationsController {
constructor(private readonly automations: AutomationsService) {}
@Get()
list(@CurrentUser() user: { organizationId: string }) {
return this.automations.list(user.organizationId);
}
@Post()
create(
@CurrentUser() user: { organizationId: string },
@Body() dto: CreateAutomationRuleDto,
) {
return this.automations.create(user.organizationId, dto);
}
@Patch(':id')
update(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
@Body() dto: UpdateAutomationRuleDto,
) {
return this.automations.update(user.organizationId, id, dto);
}
@Delete(':id')
async remove(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
) {
await this.automations.remove(user.organizationId, id);
return { ok: true };
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { LoyaltyModule } from '../loyalty/loyalty.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { AutomationsController } from './automations.controller';
import { AutomationsService } from './automations.service';
@Module({
imports: [AuthModule, NotificationsModule, LoyaltyModule],
controllers: [AutomationsController],
providers: [AutomationsService],
exports: [AutomationsService],
})
export class AutomationsModule {}
@@ -0,0 +1,223 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type {
AutomationAction,
AutomationRuleConfig,
AutomationRuleDto,
AutomationTrigger,
CreateAutomationRuleRequest,
UpdateAutomationRuleRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { LoyaltyService } from '../loyalty/loyalty.service';
export type AutomationContext = {
organizationId: string;
entityType: 'deal' | 'ticket';
entityId: string;
title: string;
personId?: string | null;
companyId?: string | null;
dealId?: string | null;
ownerId?: string | null;
assigneeId?: string | null;
};
@Injectable()
export class AutomationsService {
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
private readonly loyalty: LoyaltyService,
) {}
async list(organizationId: string): Promise<AutomationRuleDto[]> {
const rows = await this.prisma.automationRule.findMany({
where: { organizationId },
orderBy: [{ isActive: 'desc' }, { updatedAt: 'desc' }],
});
return rows.map((r) => this.toDto(r));
}
async create(
organizationId: string,
dto: CreateAutomationRuleRequest,
): Promise<AutomationRuleDto> {
const row = await this.prisma.automationRule.create({
data: {
organizationId,
name: dto.name.trim(),
trigger: dto.trigger,
action: dto.action,
isActive: dto.isActive ?? true,
config: (dto.config ?? {}) as Prisma.InputJsonValue,
},
});
return this.toDto(row);
}
async update(
organizationId: string,
id: string,
dto: UpdateAutomationRuleRequest,
): Promise<AutomationRuleDto> {
const existing = await this.prisma.automationRule.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('قانون پیدا نشد');
const row = await this.prisma.automationRule.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(dto.trigger !== undefined ? { trigger: dto.trigger } : {}),
...(dto.action !== undefined ? { action: dto.action } : {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
...(dto.config !== undefined
? { config: dto.config as Prisma.InputJsonValue }
: {}),
},
});
return this.toDto(row);
}
async remove(organizationId: string, id: string): Promise<void> {
const existing = await this.prisma.automationRule.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('قانون پیدا نشد');
await this.prisma.automationRule.delete({ where: { id } });
}
async runTrigger(
trigger: AutomationTrigger,
ctx: AutomationContext,
): Promise<void> {
const rules = await this.prisma.automationRule.findMany({
where: {
organizationId: ctx.organizationId,
isActive: true,
trigger,
},
});
for (const rule of rules) {
try {
await this.execute(rule.action as AutomationAction, asConfig(rule.config), ctx);
} catch {
// قوانین نباید شکست عملیات اصلی را ایجاد کنند
}
}
}
private async execute(
action: AutomationAction,
config: AutomationRuleConfig,
ctx: AutomationContext,
) {
const title =
(config.title?.trim() || defaultTitle(action, ctx.title)).slice(0, 200);
if (action === 'create_activity') {
const assigneeId =
config.assigneeId?.trim() ||
ctx.assigneeId ||
ctx.ownerId ||
null;
await this.prisma.activity.create({
data: {
organizationId: ctx.organizationId,
type: (config.activityType as never) || 'task',
title,
description: config.body?.trim() || null,
personId: ctx.personId ?? null,
companyId: ctx.companyId ?? null,
dealId: ctx.dealId ?? (ctx.entityType === 'deal' ? ctx.entityId : null),
assigneeId,
},
});
if (assigneeId) {
await this.notifications.create({
organizationId: ctx.organizationId,
userId: assigneeId,
type: 'automation',
title: 'فعالیت خودکار',
body: title,
link:
ctx.entityType === 'deal'
? `/deals/${ctx.entityId}`
: `/tickets/${ctx.entityId}`,
entityType: ctx.entityType,
entityId: ctx.entityId,
});
}
return;
}
if (action === 'create_notification') {
const userId =
config.notifyUserId?.trim() ||
ctx.assigneeId ||
ctx.ownerId ||
null;
if (!userId) return;
await this.notifications.create({
organizationId: ctx.organizationId,
userId,
type: 'automation',
title,
body: config.body?.trim() || ctx.title,
link:
ctx.entityType === 'deal'
? `/deals/${ctx.entityId}`
: `/tickets/${ctx.entityId}`,
entityType: ctx.entityType,
entityId: ctx.entityId,
});
return;
}
if (action === 'add_loyalty_points') {
const points = Math.max(1, Math.trunc(config.points ?? 100));
if (!ctx.personId) return;
await this.loyalty.addPointsByOrg(
ctx.organizationId,
ctx.personId,
points,
);
}
}
private toDto(row: {
id: string;
name: string;
isActive: boolean;
trigger: string;
action: string;
config: unknown;
createdAt: Date;
updatedAt: Date;
}): AutomationRuleDto {
return {
id: row.id,
name: row.name,
isActive: row.isActive,
trigger: row.trigger as AutomationTrigger,
action: row.action as AutomationAction,
config: asConfig(row.config),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
function asConfig(raw: unknown): AutomationRuleConfig {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
return raw as AutomationRuleConfig;
}
function defaultTitle(action: AutomationAction, entityTitle: string) {
if (action === 'create_activity') return `پیگیری خودکار: ${entityTitle}`;
if (action === 'create_notification') return `اتوماسیون: ${entityTitle}`;
return entityTitle;
}
+97
View File
@@ -0,0 +1,97 @@
import { Type } from 'class-transformer';
import {
IsBoolean,
IsIn,
IsInt,
IsObject,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
const TRIGGERS = ['deal_won', 'ticket_resolved'] as const;
const ACTIONS = [
'create_activity',
'create_notification',
'add_loyalty_points',
] as const;
export class CreateAutomationRuleDto {
@IsString()
@MinLength(2)
@MaxLength(120)
name!: string;
@IsIn(TRIGGERS)
trigger!: (typeof TRIGGERS)[number];
@IsIn(ACTIONS)
action!: (typeof ACTIONS)[number];
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsObject()
config?: Record<string, unknown>;
}
export class UpdateAutomationRuleDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(120)
name?: string;
@IsOptional()
@IsIn(TRIGGERS)
trigger?: (typeof TRIGGERS)[number];
@IsOptional()
@IsIn(ACTIONS)
action?: (typeof ACTIONS)[number];
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsObject()
config?: Record<string, unknown>;
}
export class AutomationConfigDto {
@IsOptional()
@IsString()
@MaxLength(200)
title?: string;
@IsOptional()
@IsString()
@MaxLength(1000)
body?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
assigneeId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
notifyUserId?: string | null;
@IsOptional()
@IsIn(['call', 'meeting', 'task', 'note'])
activityType?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
points?: number;
}
+12
View File
@@ -27,6 +27,9 @@ import {
const personInclude = {
company: { select: { id: true, name: true } },
owner: { select: { id: true, fullName: true } },
loyaltyTier: {
select: { id: true, name: true, minPoints: true, color: true },
},
} satisfies Prisma.PersonInclude;
type PersonRow = {
@@ -39,10 +42,17 @@ type PersonRow = {
position: string | null;
notes: string | null;
customFields: unknown;
loyaltyPoints: number;
createdAt: Date;
updatedAt: Date;
company: { id: string; name: string } | null;
owner: { id: string; fullName: string } | null;
loyaltyTier: {
id: string;
name: string;
minPoints: number;
color: string | null;
} | null;
};
@Injectable()
@@ -229,6 +239,8 @@ export class PeopleService {
position: row.position,
notes: row.notes,
customFields: asCustomFields(row.customFields),
loyaltyPoints: row.loyaltyPoints ?? 0,
loyaltyTier: row.loyaltyTier,
company: row.company,
owner: row.owner,
createdAt: row.createdAt.toISOString(),
+60
View File
@@ -0,0 +1,60 @@
import { Type } from 'class-transformer';
import {
IsInt,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
export class CreateLoyaltyTierDto {
@IsString()
@MinLength(2)
@MaxLength(80)
name!: string;
@Type(() => Number)
@IsInt()
@Min(0)
minPoints!: number;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@MaxLength(32)
color?: string | null;
}
export class UpdateLoyaltyTierDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(80)
name?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
minPoints?: number;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@MaxLength(32)
color?: string | null;
}
export class AdjustLoyaltyPointsDto {
@Type(() => Number)
@IsNumber()
delta!: number;
@IsOptional()
@IsString()
@MaxLength(200)
reason?: string;
}
@@ -0,0 +1,73 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
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 {
AdjustLoyaltyPointsDto,
CreateLoyaltyTierDto,
UpdateLoyaltyTierDto,
} from './dto';
import { LoyaltyService } from './loyalty.service';
@Controller('loyalty')
@UseGuards(JwtAuthGuard, RolesGuard)
export class LoyaltyController {
constructor(private readonly loyalty: LoyaltyService) {}
@Get('tiers')
listTiers(@CurrentUser() user: Actor) {
return this.loyalty.listTiers(user.organizationId);
}
@Post('tiers')
@Roles('owner', 'admin')
createTier(
@CurrentUser() user: Actor,
@Body() dto: CreateLoyaltyTierDto,
) {
return this.loyalty.createTier(user.organizationId, dto);
}
@Patch('tiers/:id')
@Roles('owner', 'admin')
updateTier(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: UpdateLoyaltyTierDto,
) {
return this.loyalty.updateTier(user.organizationId, id, dto);
}
@Delete('tiers/:id')
@Roles('owner', 'admin')
async deleteTier(@CurrentUser() user: Actor, @Param('id') id: string) {
await this.loyalty.deleteTier(user.organizationId, id);
return { ok: true };
}
@Get('members')
listMembers(@CurrentUser() user: Actor, @Query('q') q?: string) {
return this.loyalty.listMembers(user, q);
}
@Post('members/:personId/adjust')
adjust(
@CurrentUser() user: Actor,
@Param('personId') personId: string,
@Body() dto: AdjustLoyaltyPointsDto,
) {
return this.loyalty.adjustPoints(user, personId, dto.delta);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { LoyaltyController } from './loyalty.controller';
import { LoyaltyService } from './loyalty.service';
@Module({
imports: [AuthModule],
controllers: [LoyaltyController],
providers: [LoyaltyService],
exports: [LoyaltyService],
})
export class LoyaltyModule {}
+266
View File
@@ -0,0 +1,266 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateLoyaltyTierRequest,
LoyaltyMemberDto,
LoyaltyTierDto,
UpdateLoyaltyTierRequest,
} from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { type Actor, ownerWhere } from '../common/record-scope';
const DEFAULT_TIERS = [
{ name: 'برنزی', minPoints: 0, color: '#b87333' },
{ name: 'نقره‌ای', minPoints: 500, color: '#9aa0a6' },
{ name: 'طلایی', minPoints: 2000, color: '#d4af37' },
] as const;
@Injectable()
export class LoyaltyService {
constructor(private readonly prisma: PrismaService) {}
async ensureDefaultTiers(organizationId: string): Promise<void> {
const count = await this.prisma.loyaltyTier.count({
where: { organizationId },
});
if (count > 0) return;
await this.prisma.loyaltyTier.createMany({
data: DEFAULT_TIERS.map((t, position) => ({
organizationId,
name: t.name,
minPoints: t.minPoints,
color: t.color,
position,
})),
});
}
async listTiers(organizationId: string): Promise<LoyaltyTierDto[]> {
await this.ensureDefaultTiers(organizationId);
const rows = await this.prisma.loyaltyTier.findMany({
where: { organizationId },
orderBy: [{ minPoints: 'asc' }, { position: 'asc' }],
include: { _count: { select: { people: true } } },
});
return rows.map((r) => ({
id: r.id,
name: r.name,
minPoints: r.minPoints,
position: r.position,
color: r.color,
memberCount: r._count.people,
}));
}
async createTier(
organizationId: string,
dto: CreateLoyaltyTierRequest,
): Promise<LoyaltyTierDto> {
await this.ensureDefaultTiers(organizationId);
const maxPos = await this.prisma.loyaltyTier.aggregate({
where: { organizationId },
_max: { position: true },
});
const row = await this.prisma.loyaltyTier.create({
data: {
organizationId,
name: dto.name.trim(),
minPoints: Math.max(0, Math.floor(dto.minPoints)),
color: emptyToNull(dto.color),
position: (maxPos._max.position ?? -1) + 1,
},
});
await this.recomputeAllTiers(organizationId);
return {
id: row.id,
name: row.name,
minPoints: row.minPoints,
position: row.position,
color: row.color,
memberCount: 0,
};
}
async updateTier(
organizationId: string,
id: string,
dto: UpdateLoyaltyTierRequest,
): Promise<LoyaltyTierDto> {
const existing = await this.prisma.loyaltyTier.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('سطح پیدا نشد');
const row = await this.prisma.loyaltyTier.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(dto.minPoints !== undefined
? { minPoints: Math.max(0, Math.floor(dto.minPoints)) }
: {}),
...('color' in dto ? { color: emptyToNull(dto.color ?? null) } : {}),
},
include: { _count: { select: { people: true } } },
});
await this.recomputeAllTiers(organizationId);
return {
id: row.id,
name: row.name,
minPoints: row.minPoints,
position: row.position,
color: row.color,
memberCount: row._count.people,
};
}
async deleteTier(organizationId: string, id: string): Promise<void> {
const existing = await this.prisma.loyaltyTier.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('سطح پیدا نشد');
await this.prisma.person.updateMany({
where: { organizationId, loyaltyTierId: id },
data: { loyaltyTierId: null },
});
await this.prisma.loyaltyTier.delete({ where: { id } });
await this.recomputeAllTiers(organizationId);
}
async listMembers(
actor: Actor,
q?: string,
): Promise<LoyaltyMemberDto[]> {
await this.ensureDefaultTiers(actor.organizationId);
const rows = await this.prisma.person.findMany({
where: {
organizationId: actor.organizationId,
...ownerWhere(actor),
...(q?.trim()
? {
OR: [
{ firstName: { contains: q.trim(), mode: 'insensitive' } },
{ lastName: { contains: q.trim(), mode: 'insensitive' } },
{ mobile: { contains: q.trim() } },
{ email: { contains: q.trim(), mode: 'insensitive' } },
],
}
: {}),
},
include: {
company: { select: { id: true, name: true } },
loyaltyTier: {
select: { id: true, name: true, minPoints: true, color: true },
},
},
orderBy: [{ loyaltyPoints: 'desc' }, { lastName: 'asc' }],
take: 200,
});
return rows.map((r) => ({
id: r.id,
fullName: `${r.firstName} ${r.lastName}`.trim(),
mobile: r.mobile,
email: r.email,
loyaltyPoints: r.loyaltyPoints,
loyaltyTier: r.loyaltyTier,
company: r.company,
}));
}
async adjustPoints(
actor: Actor,
personId: string,
delta: number,
): Promise<LoyaltyMemberDto> {
if (!Number.isFinite(delta) || delta === 0) {
throw new BadRequestException('مقدار امتیاز نامعتبر است');
}
const person = await this.prisma.person.findFirst({
where: {
id: personId,
organizationId: actor.organizationId,
...ownerWhere(actor),
},
});
if (!person) throw new NotFoundException('شخص پیدا نشد');
const next = Math.max(0, person.loyaltyPoints + Math.trunc(delta));
const tierId = await this.resolveTierId(actor.organizationId, next);
const updated = await this.prisma.person.update({
where: { id: personId },
data: { loyaltyPoints: next, loyaltyTierId: tierId },
include: {
company: { select: { id: true, name: true } },
loyaltyTier: {
select: { id: true, name: true, minPoints: true, color: true },
},
},
});
return {
id: updated.id,
fullName: `${updated.firstName} ${updated.lastName}`.trim(),
mobile: updated.mobile,
email: updated.email,
loyaltyPoints: updated.loyaltyPoints,
loyaltyTier: updated.loyaltyTier,
company: updated.company,
};
}
/** برای اتوماسیون — بدون ACL مالکیت */
async addPointsByOrg(
organizationId: string,
personId: string,
points: number,
): Promise<void> {
if (!personId || !points) return;
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId },
});
if (!person) return;
const next = Math.max(0, person.loyaltyPoints + Math.trunc(points));
const tierId = await this.resolveTierId(organizationId, next);
await this.prisma.person.update({
where: { id: personId },
data: { loyaltyPoints: next, loyaltyTierId: tierId },
});
}
async resolveTierId(
organizationId: string,
points: number,
): Promise<string | null> {
await this.ensureDefaultTiers(organizationId);
const tier = await this.prisma.loyaltyTier.findFirst({
where: { organizationId, minPoints: { lte: points } },
orderBy: { minPoints: 'desc' },
});
return tier?.id ?? null;
}
private async recomputeAllTiers(organizationId: string) {
const people = await this.prisma.person.findMany({
where: { organizationId },
select: { id: true, loyaltyPoints: true },
});
const tiers = await this.prisma.loyaltyTier.findMany({
where: { organizationId },
orderBy: { minPoints: 'desc' },
});
for (const p of people) {
const tier = tiers.find((t) => t.minPoints <= p.loyaltyPoints) ?? null;
await this.prisma.person.update({
where: { id: p.id },
data: { loyaltyTierId: tier?.id ?? null },
});
}
}
}
function emptyToNull(v?: string | null) {
if (v == null) return null;
const t = v.trim();
return t ? t : null;
}
+18
View File
@@ -15,6 +15,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { PipelinesService } from './pipelines.service';
import { toJsonCustomFields } from '../common/custom-fields';
import { NotificationsService } from '../notifications/notifications.service';
import { AutomationsService } from '../automations/automations.service';
import {
type Actor,
canReassignOwner,
@@ -37,6 +38,7 @@ export class DealsService {
private readonly prisma: PrismaService,
private readonly pipelines: PipelinesService,
private readonly notifications: NotificationsService,
private readonly automations: AutomationsService,
) {}
async get(
@@ -240,6 +242,9 @@ export class DealsService {
pipelineId: true,
stageId: true,
ownerId: true,
status: true,
personId: true,
companyId: true,
},
});
if (!deal) throw new NotFoundException('معامله پیدا نشد');
@@ -285,6 +290,19 @@ export class DealsService {
});
}
if (status === 'won' && deal.status !== 'won') {
await this.automations.runTrigger('deal_won', {
organizationId,
entityType: 'deal',
entityId: deal.id,
title: deal.title,
personId: deal.personId,
companyId: deal.companyId,
dealId: deal.id,
ownerId: deal.ownerId,
});
}
return this.pipelines.toDealDto(row);
}
+3 -2
View File
@@ -1,13 +1,14 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { DealsController } from './deals.controller';
import { DealsService } from './deals.service';
import { PipelinesController } from './pipelines.controller';
import { PipelinesService } from './pipelines.service';
import { NotificationsModule } from '../notifications/notifications.module';
import { AuthModule } from '../auth/auth.module';
import { AutomationsModule } from '../automations/automations.module';
@Module({
imports: [NotificationsModule, AuthModule],
imports: [NotificationsModule, AuthModule, forwardRef(() => AutomationsModule)],
controllers: [PipelinesController, DealsController],
providers: [PipelinesService, DealsService],
exports: [PipelinesService, DealsService],
+101
View File
@@ -0,0 +1,101 @@
import { Type } from 'class-transformer';
import {
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
export class CreateProjectBoardDto {
@IsString()
@MinLength(2)
@MaxLength(120)
name!: string;
}
export class CreateProjectCardDto {
@IsString()
@MinLength(2)
@MaxLength(200)
title!: string;
@IsOptional()
@IsString()
boardId?: string;
@IsOptional()
@IsString()
stageId?: string;
@IsOptional()
@IsString()
@MaxLength(4000)
notes?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
personId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
companyId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
dealId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
ownerId?: string | null;
}
export class UpdateProjectCardDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(200)
title?: string;
@IsOptional()
@IsString()
@MaxLength(4000)
notes?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
personId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
companyId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
dealId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
ownerId?: string | null;
}
export class MoveProjectCardDto {
@IsString()
stageId!: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
position?: number;
}
@@ -0,0 +1,89 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
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 {
CreateProjectBoardDto,
CreateProjectCardDto,
MoveProjectCardDto,
UpdateProjectCardDto,
} from './dto';
import { ProjectBoardsService } from './project-boards.service';
type AuthUser = { organizationId: string; userId: string; role: string };
@Controller()
@UseGuards(JwtAuthGuard, RolesGuard)
export class ProjectBoardsController {
constructor(private readonly boards: ProjectBoardsService) {}
@Get('project-boards')
listBoards(@CurrentUser() user: AuthUser) {
return this.boards.list(user.organizationId);
}
@Get('project-boards/board')
board(
@CurrentUser() user: AuthUser,
@Query('boardId') boardId?: string,
) {
return this.boards.getBoard(user.organizationId, boardId, user);
}
@Post('project-boards')
@Roles('owner', 'admin')
createBoard(
@CurrentUser() user: AuthUser,
@Body() dto: CreateProjectBoardDto,
) {
return this.boards.create(user.organizationId, dto.name);
}
@Get('project-cards/:id')
getCard(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.boards.getCard(user.organizationId, id, user);
}
@Post('project-cards')
createCard(
@CurrentUser() user: AuthUser,
@Body() dto: CreateProjectCardDto,
) {
return this.boards.createCard(user.organizationId, user, dto);
}
@Patch('project-cards/:id')
updateCard(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: UpdateProjectCardDto,
) {
return this.boards.updateCard(user.organizationId, id, user, dto);
}
@Patch('project-cards/:id/move')
moveCard(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: MoveProjectCardDto,
) {
return this.boards.moveCard(user.organizationId, id, user, dto);
}
@Delete('project-cards/:id')
async removeCard(@CurrentUser() user: AuthUser, @Param('id') id: string) {
await this.boards.removeCard(user.organizationId, id, user);
return { ok: true };
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ProjectBoardsController } from './project-boards.controller';
import { ProjectBoardsService } from './project-boards.service';
@Module({
imports: [AuthModule],
controllers: [ProjectBoardsController],
providers: [ProjectBoardsService],
exports: [ProjectBoardsService],
})
export class ProjectBoardsModule {}
@@ -0,0 +1,390 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateProjectCardRequest,
MoveProjectCardRequest,
ProjectBoardBoardDto,
ProjectBoardDto,
ProjectCardDto,
UpdateProjectCardRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
type Actor,
canReassignOwner,
ownerWhere,
resolveOwnerId,
} from '../common/record-scope';
const DEFAULT_STAGES = ['صف', 'در حال انجام', 'بازبینی', 'انجام‌شده'] as const;
const cardInclude = {
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } },
owner: { select: { id: true, fullName: true } },
} satisfies Prisma.ProjectCardInclude;
type CardActor = Pick<Actor, 'userId' | 'role'>;
@Injectable()
export class ProjectBoardsService {
constructor(private readonly prisma: PrismaService) {}
async ensureDefaultBoard(organizationId: string): Promise<ProjectBoardDto> {
const existing = await this.prisma.projectBoard.findFirst({
where: { organizationId, isDefault: true },
include: { stages: { orderBy: { position: 'asc' } } },
});
if (existing) return this.toBoardDto(existing);
const created = await this.prisma.projectBoard.create({
data: {
organizationId,
name: 'پروژه داخلی',
isDefault: true,
stages: {
create: DEFAULT_STAGES.map((name, position) => ({ name, position })),
},
},
include: { stages: { orderBy: { position: 'asc' } } },
});
return this.toBoardDto(created);
}
async list(organizationId: string): Promise<ProjectBoardDto[]> {
await this.ensureDefaultBoard(organizationId);
const rows = await this.prisma.projectBoard.findMany({
where: { organizationId },
include: { stages: { orderBy: { position: 'asc' } } },
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
});
return rows.map((row) => this.toBoardDto(row));
}
async create(organizationId: string, name: string): Promise<ProjectBoardDto> {
await this.ensureDefaultBoard(organizationId);
const created = await this.prisma.projectBoard.create({
data: {
organizationId,
name: name.trim(),
isDefault: false,
stages: {
create: DEFAULT_STAGES.map((n, position) => ({ name: n, position })),
},
},
include: { stages: { orderBy: { position: 'asc' } } },
});
return this.toBoardDto(created);
}
async getBoard(
organizationId: string,
boardId?: string,
actor?: CardActor,
): Promise<ProjectBoardBoardDto> {
const board = boardId
? await this.prisma.projectBoard.findFirst({
where: { id: boardId, organizationId },
include: { stages: { orderBy: { position: 'asc' } } },
})
: null;
const resolved =
board ??
(await this.prisma.projectBoard.findFirst({
where: { organizationId, isDefault: true },
include: { stages: { orderBy: { position: 'asc' } } },
}));
if (!resolved) {
const created = await this.ensureDefaultBoard(organizationId);
return this.getBoard(organizationId, created.id, actor);
}
const cards = await this.prisma.projectCard.findMany({
where: {
organizationId,
boardId: resolved.id,
...(actor ? ownerWhere(actor) : {}),
},
include: cardInclude,
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
});
const byStage = new Map<string, ProjectCardDto[]>();
for (const stage of resolved.stages) byStage.set(stage.id, []);
for (const card of cards) {
const list = byStage.get(card.stageId);
if (list) list.push(this.toCardDto(card));
}
return {
board: this.toBoardDto(resolved),
stages: resolved.stages.map((stage) => ({
id: stage.id,
name: stage.name,
position: stage.position,
cards: byStage.get(stage.id) ?? [],
})),
};
}
async getCard(
organizationId: string,
id: string,
actor: CardActor,
): Promise<ProjectCardDto> {
const row = await this.prisma.projectCard.findFirst({
where: { id, organizationId, ...ownerWhere(actor) },
include: cardInclude,
});
if (!row) throw new NotFoundException('کارت پیدا نشد');
return this.toCardDto(row);
}
async createCard(
organizationId: string,
actor: CardActor,
dto: CreateProjectCardRequest,
): Promise<ProjectCardDto> {
const board =
dto.boardId != null
? await this.prisma.projectBoard.findFirst({
where: { id: dto.boardId, organizationId },
include: { stages: { orderBy: { position: 'asc' } } },
})
: null;
const resolved =
board ??
(await this.prisma.projectBoard.findFirst({
where: { organizationId, isDefault: true },
include: { stages: { orderBy: { position: 'asc' } } },
}));
if (!resolved) {
await this.ensureDefaultBoard(organizationId);
return this.createCard(organizationId, actor, dto);
}
const stage =
(dto.stageId
? resolved.stages.find((s) => s.id === dto.stageId)
: resolved.stages[0]) ?? resolved.stages[0];
if (!stage) throw new BadRequestException('برد مرحله‌ای ندارد');
await this.assertLinks(
organizationId,
actor,
dto.personId,
dto.companyId,
dto.dealId,
);
const maxPos = await this.prisma.projectCard.aggregate({
where: { organizationId, stageId: stage.id },
_max: { position: true },
});
const created = await this.prisma.projectCard.create({
data: {
organizationId,
boardId: resolved.id,
stageId: stage.id,
title: dto.title.trim(),
notes: emptyToNull(dto.notes),
personId: emptyToNull(dto.personId),
companyId: emptyToNull(dto.companyId),
dealId: emptyToNull(dto.dealId),
ownerId: resolveOwnerId(actor, dto.ownerId),
position: (maxPos._max.position ?? -1) + 1,
},
include: cardInclude,
});
return this.toCardDto(created);
}
async updateCard(
organizationId: string,
id: string,
actor: CardActor,
dto: UpdateProjectCardRequest,
): Promise<ProjectCardDto> {
const existing = await this.prisma.projectCard.findFirst({
where: { id, organizationId, ...ownerWhere(actor) },
});
if (!existing) throw new NotFoundException('کارت پیدا نشد');
if ('personId' in dto || 'companyId' in dto || 'dealId' in dto) {
await this.assertLinks(
organizationId,
actor,
dto.personId ?? existing.personId,
dto.companyId ?? existing.companyId,
dto.dealId ?? existing.dealId,
);
}
const data: Prisma.ProjectCardUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim();
if ('notes' in dto) data.notes = emptyToNull(dto.notes ?? null);
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
if ('ownerId' in dto) {
if (!canReassignOwner(actor.role) && dto.ownerId !== existing.ownerId) {
throw new BadRequestException('تغییر مسئول مجاز نیست');
}
data.ownerId = emptyToNull(dto.ownerId ?? null);
}
const row = await this.prisma.projectCard.update({
where: { id },
data,
include: cardInclude,
});
return this.toCardDto(row);
}
async moveCard(
organizationId: string,
id: string,
actor: CardActor,
dto: MoveProjectCardRequest,
): Promise<ProjectCardDto> {
const card = await this.prisma.projectCard.findFirst({
where: { id, organizationId, ...ownerWhere(actor) },
select: { id: true, boardId: true },
});
if (!card) throw new NotFoundException('کارت پیدا نشد');
const stage = await this.prisma.projectBoardStage.findFirst({
where: { id: dto.stageId, boardId: card.boardId },
});
if (!stage) throw new BadRequestException('مرحله در این برد معتبر نیست');
const position =
dto.position ??
((
await this.prisma.projectCard.aggregate({
where: { organizationId, stageId: stage.id, NOT: { id } },
_max: { position: true },
})
)._max.position ?? -1) + 1;
const row = await this.prisma.projectCard.update({
where: { id },
data: { stageId: stage.id, position },
include: cardInclude,
});
return this.toCardDto(row);
}
async removeCard(
organizationId: string,
id: string,
actor: CardActor,
): Promise<void> {
const found = await this.prisma.projectCard.findFirst({
where: { id, organizationId, ...ownerWhere(actor) },
select: { id: true },
});
if (!found) throw new NotFoundException('کارت پیدا نشد');
await this.prisma.projectCard.delete({ where: { id } });
}
toCardDto(row: {
id: string;
title: string;
notes: string | null;
position: number;
boardId: string;
stageId: string;
createdAt: Date;
updatedAt: Date;
person: { id: string; firstName: string; lastName: string } | null;
company: { id: string; name: string } | null;
deal: { id: string; title: string } | null;
owner: { id: string; fullName: string } | null;
}): ProjectCardDto {
return {
id: row.id,
title: row.title,
notes: row.notes,
position: row.position,
boardId: row.boardId,
stageId: row.stageId,
person: row.person
? {
id: row.person.id,
fullName: `${row.person.firstName} ${row.person.lastName}`.trim(),
}
: null,
company: row.company,
deal: row.deal,
owner: row.owner,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
private toBoardDto(row: {
id: string;
name: string;
isDefault: boolean;
stages: Array<{ id: string; name: string; position: number }>;
}): ProjectBoardDto {
return {
id: row.id,
name: row.name,
isDefault: row.isDefault,
stages: row.stages.map((s) => ({
id: s.id,
name: s.name,
position: s.position,
})),
};
}
private async assertLinks(
organizationId: string,
actor: CardActor,
personId?: string | null,
companyId?: string | null,
dealId?: string | null,
) {
if (personId) {
const p = await this.prisma.person.findFirst({
where: { id: personId, organizationId, ...ownerWhere(actor) },
select: { id: true },
});
if (!p) throw new BadRequestException('شخص معتبر نیست');
}
if (companyId) {
const c = await this.prisma.company.findFirst({
where: { id: companyId, organizationId, ...ownerWhere(actor) },
select: { id: true },
});
if (!c) throw new BadRequestException('شرکت معتبر نیست');
}
if (dealId) {
const d = await this.prisma.deal.findFirst({
where: { id: dealId, organizationId, ...ownerWhere(actor) },
select: { id: true },
});
if (!d) throw new BadRequestException('معامله معتبر نیست');
}
}
}
function emptyToNull(v?: string | null) {
if (v == null) return null;
const t = v.trim();
return t ? t : null;
}
+3 -2
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module';
import { AutomationsModule } from '../automations/automations.module';
import { TicketsController } from './tickets.controller';
import { TicketsService } from './tickets.service';
@Module({
imports: [NotificationsModule],
imports: [NotificationsModule, forwardRef(() => AutomationsModule)],
controllers: [TicketsController],
providers: [TicketsService],
exports: [TicketsService],
+21
View File
@@ -20,6 +20,7 @@ import {
ticketAccessWhere,
} from '../common/record-scope';
import { NotificationsService } from '../notifications/notifications.service';
import { AutomationsService } from '../automations/automations.service';
const include = {
person: { select: { id: true, firstName: true, lastName: true } },
@@ -40,6 +41,7 @@ export class TicketsService {
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
private readonly automations: AutomationsService,
) {}
async list(
@@ -325,6 +327,25 @@ export class TicketsService {
}
await this.prisma.ticket.update({ where: { id }, data });
const becameResolved =
(dto.status === 'resolved' || dto.status === 'closed') &&
existing.status !== 'resolved' &&
existing.status !== 'closed';
if (becameResolved) {
await this.automations.runTrigger('ticket_resolved', {
organizationId: actor.organizationId,
entityType: 'ticket',
entityId: existing.id,
title: existing.subject,
personId: existing.personId,
companyId: existing.companyId,
dealId: existing.dealId,
assigneeId: existing.assigneeId,
ownerId: existing.assigneeId,
});
}
return this.get(actor, id);
}
+3
View File
@@ -190,6 +190,8 @@ onUnmounted(() => {
<RouterLink to="/activities">فعالیت‌ها</RouterLink>
<RouterLink to="/activities/calendar">تقویم فعالیت‌ها</RouterLink>
<RouterLink to="/pipeline">کاریز فروش</RouterLink>
<RouterLink to="/projects">کاریز پروژه</RouterLink>
<RouterLink to="/loyalty">باشگاه مشتریان</RouterLink>
<RouterLink to="/sales/forecast">هدف و پیش‌بینی</RouterLink>
<RouterLink to="/reports">گزارشات</RouterLink>
<RouterLink to="/tickets">تیکت‌ها</RouterLink>
@@ -199,6 +201,7 @@ onUnmounted(() => {
<RouterLink to="/notes">یادداشت‌ها</RouterLink>
<template v-if="canSettings">
<RouterLink to="/settings/team">تیم و نقش‌ها</RouterLink>
<RouterLink to="/settings/automations">اتوماسیون</RouterLink>
<RouterLink to="/settings/lead-forms">فرم‌ساز سرنخ</RouterLink>
<RouterLink to="/settings/custom-fields">فیلدهای سفارشی</RouterLink>
<RouterLink to="/settings/products">محصولات</RouterLink>
+26
View File
@@ -118,6 +118,26 @@ export const router = createRouter({
name: 'pipeline',
component: () => import('@/views/PipelineBoardView.vue'),
},
{
path: 'projects',
name: 'projects',
component: () => import('@/views/ProjectBoardView.vue'),
},
{
path: 'project-cards/new',
name: 'project-card-new',
component: () => import('@/views/ProjectCardFormView.vue'),
},
{
path: 'project-cards/:id',
name: 'project-card-detail',
component: () => import('@/views/ProjectCardFormView.vue'),
},
{
path: 'loyalty',
name: 'loyalty',
component: () => import('@/views/LoyaltyClubView.vue'),
},
{
path: 'deals/new',
name: 'deal-new',
@@ -138,6 +158,12 @@ export const router = createRouter({
name: 'notes',
component: () => import('@/views/NotesView.vue'),
},
{
path: 'settings/automations',
name: 'automations',
component: () => import('@/views/AutomationsSettingsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'settings/lead-forms',
name: 'lead-forms',
@@ -0,0 +1,347 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
AutomationAction,
AutomationRuleDto,
AutomationTrigger,
CreateAutomationRuleRequest,
} from '@mento/shared';
const AUTOMATION_TRIGGER_LABELS: Record<AutomationTrigger, string> = {
deal_won: 'معامله برنده شد',
ticket_resolved: 'تیکت حل شد',
};
const AUTOMATION_ACTION_LABELS: Record<AutomationAction, string> = {
create_activity: 'ساخت فعالیت',
create_notification: 'ارسال اعلان',
add_loyalty_points: 'افزودن امتیاز باشگاه',
};
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const items = ref<AutomationRuleDto[]>([]);
const editingId = ref<string | null>(null);
const name = ref('');
const trigger = ref<AutomationTrigger>('deal_won');
const action = ref<AutomationAction>('create_activity');
const isActive = ref(true);
const title = ref('');
const body = ref('');
const points = ref(100);
const triggers = Object.keys(AUTOMATION_TRIGGER_LABELS) as AutomationTrigger[];
const actions = Object.keys(AUTOMATION_ACTION_LABELS) as AutomationAction[];
function resetForm() {
editingId.value = null;
name.value = '';
trigger.value = 'deal_won';
action.value = 'create_activity';
isActive.value = true;
title.value = '';
body.value = '';
points.value = 100;
}
function startEdit(row: AutomationRuleDto) {
editingId.value = row.id;
name.value = row.name;
trigger.value = row.trigger;
action.value = row.action;
isActive.value = row.isActive;
title.value = row.config.title ?? '';
body.value = row.config.body ?? '';
points.value = row.config.points ?? 100;
ok.value = '';
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<AutomationRuleDto[]>('/automations');
items.value = data;
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری اتوماسیون ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function save() {
if (name.value.trim().length < 2) {
error.value = 'نام قانون را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
ok.value = '';
try {
const payload: CreateAutomationRuleRequest = {
name: name.value.trim(),
trigger: trigger.value,
action: action.value,
isActive: isActive.value,
config: {
title: title.value.trim() || undefined,
body: body.value.trim() || undefined,
points:
action.value === 'add_loyalty_points'
? Math.max(1, Math.floor(points.value))
: undefined,
},
};
if (editingId.value) {
await api.patch(`/automations/${editingId.value}`, payload);
ok.value = 'قانون به‌روز شد.';
} else {
await api.post('/automations', payload);
ok.value = 'قانون ساخته شد.';
}
resetForm();
await load();
} catch {
error.value = 'ذخیره قانون ناموفق بود.';
} finally {
saving.value = false;
}
}
async function removeRule(id: string) {
if (!confirm('قانون حذف شود؟')) return;
try {
await api.delete(`/automations/${id}`);
await load();
} catch {
error.value = 'حذف ناموفق بود.';
}
}
async function toggleActive(row: AutomationRuleDto) {
try {
await api.patch(`/automations/${row.id}`, { isActive: !row.isActive });
await load();
} catch {
error.value = 'تغییر وضعیت ناموفق بود.';
}
}
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<p class="eyebrow">اتوماسیون</p>
<h1>اگر / آنگاه</h1>
<p class="lead">
بعد از بردن معامله یا حل شدن تیکت، فعالیت، اعلان یا امتیاز باشگاه بسازید.
</p>
</div>
<button class="btn btn-ghost" type="button" :disabled="loading" @click="load">
بروزرسانی
</button>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<form class="panel form" @submit.prevent="save">
<h2>{{ editingId ? 'ویرایش قانون' : 'قانون جدید' }}</h2>
<label>
نام
<input v-model="name" type="text" required minlength="2" maxlength="120" />
</label>
<div class="grid2">
<label>
اگر (محرک)
<select v-model="trigger">
<option v-for="t in triggers" :key="t" :value="t">
{{ AUTOMATION_TRIGGER_LABELS[t] }}
</option>
</select>
</label>
<label>
آنگاه (عمل)
<select v-model="action">
<option v-for="a in actions" :key="a" :value="a">
{{ AUTOMATION_ACTION_LABELS[a] }}
</option>
</select>
</label>
</div>
<label>
عنوان / متن عمل
<input v-model="title" type="text" maxlength="200" placeholder="اختیاری" />
</label>
<label>
توضیح
<textarea v-model="body" rows="3" maxlength="1000" placeholder="اختیاری" />
</label>
<label v-if="action === 'add_loyalty_points'">
امتیاز
<input v-model.number="points" type="number" min="1" />
</label>
<label class="check">
<input v-model="isActive" type="checkbox" />
فعال
</label>
<div class="actions">
<button class="btn" type="submit" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : editingId ? 'به‌روزرسانی' : 'ساخت' }}
</button>
<button
v-if="editingId"
class="btn btn-ghost"
type="button"
@click="resetForm"
>
انصراف
</button>
</div>
</form>
<section class="panel">
<h2>قوانین</h2>
<ul class="list">
<li v-for="row in items" :key="row.id">
<div>
<strong>{{ row.name }}</strong>
<p class="muted">
اگر {{ AUTOMATION_TRIGGER_LABELS[row.trigger] }} →
{{ AUTOMATION_ACTION_LABELS[row.action] }}
<span v-if="!row.isActive"> · غیرفعال</span>
</p>
</div>
<div class="row-actions">
<button class="link" type="button" @click="toggleActive(row)">
{{ row.isActive ? 'غیرفعال' : 'فعال' }}
</button>
<button class="link" type="button" @click="startEdit(row)">ویرایش</button>
<button class="link danger" type="button" @click="removeRule(row.id)">
حذف
</button>
</div>
</li>
</ul>
<p v-if="!items.length && !loading" class="muted">هنوز قانونی نیست.</p>
</section>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
max-width: 860px;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0.2rem 0;
}
.lead,
.muted {
color: var(--muted);
}
.panel {
border: 1px solid var(--line);
border-radius: 16px;
padding: 1rem;
background: #fff;
display: grid;
gap: 0.75rem;
}
h2 {
margin: 0;
font-size: 1.1rem;
}
.form label {
display: grid;
gap: 0.35rem;
font-size: 0.9rem;
color: var(--muted);
}
input,
select,
textarea {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.6rem 0.75rem;
font: inherit;
color: var(--ink);
}
.grid2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.65rem;
}
.check {
display: flex !important;
align-items: center;
gap: 0.45rem;
}
.actions,
.row-actions {
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.7rem;
}
.list li {
display: flex;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
border-bottom: 1px solid var(--line);
padding-bottom: 0.65rem;
}
.link {
background: none;
border: none;
color: var(--accent);
cursor: pointer;
font: inherit;
padding: 0;
}
.danger {
color: #b42318;
}
.error {
color: #b42318;
}
.ok {
color: #067647;
}
@media (max-width: 640px) {
.grid2 {
grid-template-columns: 1fr;
}
}
</style>
+341
View File
@@ -0,0 +1,341 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import type {
CreateLoyaltyTierRequest,
LoyaltyMemberDto,
LoyaltyTierDto,
} from '@mento/shared';
const auth = useAuthStore();
const canManage = computed(() => canManageSettings(auth.user?.role));
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const q = ref('');
const tiers = ref<LoyaltyTierDto[]>([]);
const members = ref<LoyaltyMemberDto[]>([]);
const tierName = ref('');
const tierMin = ref(0);
const tierColor = ref('');
const editingTierId = ref<string | null>(null);
const adjustPersonId = ref('');
const adjustDelta = ref(100);
async function load() {
loading.value = true;
error.value = '';
try {
const [tRes, mRes] = await Promise.all([
api.get<LoyaltyTierDto[]>('/loyalty/tiers'),
api.get<LoyaltyMemberDto[]>('/loyalty/members', {
params: q.value.trim() ? { q: q.value.trim() } : undefined,
}),
]);
tiers.value = tRes.data;
members.value = mRes.data;
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری باشگاه ناموفق بود.';
}
} finally {
loading.value = false;
}
}
function startEditTier(t: LoyaltyTierDto) {
editingTierId.value = t.id;
tierName.value = t.name;
tierMin.value = t.minPoints;
tierColor.value = t.color ?? '';
ok.value = '';
}
function resetTierForm() {
editingTierId.value = null;
tierName.value = '';
tierMin.value = 0;
tierColor.value = '';
}
async function saveTier() {
if (!canManage.value || tierName.value.trim().length < 2) return;
saving.value = true;
error.value = '';
ok.value = '';
try {
const body: CreateLoyaltyTierRequest = {
name: tierName.value.trim(),
minPoints: Math.max(0, Math.floor(tierMin.value)),
color: tierColor.value.trim() || null,
};
if (editingTierId.value) {
await api.patch(`/loyalty/tiers/${editingTierId.value}`, body);
ok.value = 'سطح به‌روز شد.';
} else {
await api.post('/loyalty/tiers', body);
ok.value = 'سطح ساخته شد.';
}
resetTierForm();
await load();
} catch {
error.value = 'ذخیره سطح ناموفق بود.';
} finally {
saving.value = false;
}
}
async function removeTier(id: string) {
if (!canManage.value || !confirm('سطح حذف شود؟')) return;
try {
await api.delete(`/loyalty/tiers/${id}`);
await load();
} catch {
error.value = 'حذف سطح ناموفق بود.';
}
}
async function adjustPoints() {
if (!adjustPersonId.value || !adjustDelta.value) return;
saving.value = true;
error.value = '';
ok.value = '';
try {
await api.post(`/loyalty/members/${adjustPersonId.value}/adjust`, {
delta: adjustDelta.value,
});
ok.value = 'امتیاز به‌روز شد.';
await load();
} catch {
error.value = 'تغییر امتیاز ناموفق بود.';
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<p class="eyebrow">مشتریان</p>
<h1>باشگاه مشتریان</h1>
<p class="lead">امتیاز و سطح وفاداری پایه برای اشخاص.</p>
</div>
<button class="btn btn-ghost" type="button" :disabled="loading" @click="load">
بروزرسانی
</button>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<section class="panel">
<h2>سطوح</h2>
<ul class="tiers">
<li v-for="t in tiers" :key="t.id">
<span class="swatch" :style="{ background: t.color || '#ccc' }" />
<strong>{{ t.name }}</strong>
<span class="muted">از {{ t.minPoints }} امتیاز</span>
<span class="muted">{{ t.memberCount ?? 0 }} عضو</span>
<template v-if="canManage">
<button class="link" type="button" @click="startEditTier(t)">ویرایش</button>
<button class="link danger" type="button" @click="removeTier(t.id)">حذف</button>
</template>
</li>
</ul>
<form v-if="canManage" class="tier-form" @submit.prevent="saveTier">
<input v-model="tierName" type="text" placeholder="نام سطح" required minlength="2" />
<input v-model.number="tierMin" type="number" min="0" placeholder="حداقل امتیاز" />
<input v-model="tierColor" type="text" placeholder="رنگ (مثلاً #d4af37)" />
<button class="btn" type="submit" :disabled="saving">
{{ editingTierId ? 'به‌روزرسانی سطح' : 'سطح جدید' }}
</button>
<button
v-if="editingTierId"
class="btn btn-ghost"
type="button"
@click="resetTierForm"
>
انصراف
</button>
</form>
</section>
<section class="panel">
<div class="row">
<h2>اعضا</h2>
<form class="search" @submit.prevent="load">
<input v-model="q" type="search" placeholder="جستجو…" />
<button class="btn btn-ghost" type="submit">جستجو</button>
</form>
</div>
<form class="adjust" @submit.prevent="adjustPoints">
<select v-model="adjustPersonId" required>
<option value="" disabled>انتخاب شخص</option>
<option v-for="m in members" :key="m.id" :value="m.id">
{{ m.fullName }} ({{ m.loyaltyPoints }})
</option>
</select>
<input v-model.number="adjustDelta" type="number" step="1" />
<button class="btn" type="submit" :disabled="saving">تغییر امتیاز</button>
</form>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>نام</th>
<th>امتیاز</th>
<th>سطح</th>
<th>شرکت</th>
</tr>
</thead>
<tbody>
<tr v-for="m in members" :key="m.id">
<td>{{ m.fullName }}</td>
<td>{{ m.loyaltyPoints }}</td>
<td>
<span
v-if="m.loyaltyTier"
class="tier-pill"
:style="{ borderColor: m.loyaltyTier.color || '#ccc' }"
>
{{ m.loyaltyTier.name }}
</span>
<span v-else class="muted">—</span>
</td>
<td>{{ m.company?.name || '—' }}</td>
</tr>
</tbody>
</table>
<p v-if="!members.length && !loading" class="muted">عضوی نیست.</p>
</div>
</section>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0.2rem 0;
}
.lead,
.muted {
color: var(--muted);
}
.panel {
border: 1px solid var(--line);
border-radius: 16px;
padding: 1rem;
background: #fff;
display: grid;
gap: 0.75rem;
}
h2 {
margin: 0;
font-size: 1.1rem;
}
.tiers {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.45rem;
}
.tiers li {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
align-items: center;
}
.swatch {
width: 12px;
height: 12px;
border-radius: 999px;
}
.tier-form,
.adjust,
.search,
.row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.row {
justify-content: space-between;
}
input,
select {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.55rem 0.7rem;
font: inherit;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: right;
padding: 0.55rem 0.4rem;
border-bottom: 1px solid var(--line);
font-size: 0.92rem;
}
.tier-pill {
display: inline-block;
border: 1px solid;
border-radius: 999px;
padding: 0.1rem 0.55rem;
font-size: 0.82rem;
}
.link {
background: none;
border: none;
color: var(--accent);
cursor: pointer;
font: inherit;
padding: 0;
}
.danger {
color: #b42318;
}
.error {
color: #b42318;
}
.ok {
color: #067647;
}
</style>
+13
View File
@@ -51,6 +51,8 @@ const notes = ref('');
const companyId = ref('');
const customFields = ref<Record<string, unknown>>({});
const loyaltyPoints = ref(0);
const loyaltyTierName = ref('');
@@ -72,6 +74,8 @@ function fill(p: PersonDto) {
companyId.value = p.company?.id ?? '';
customFields.value = { ...(p.customFields ?? {}) };
loyaltyPoints.value = p.loyaltyPoints ?? 0;
loyaltyTierName.value = p.loyaltyTier?.name ?? '';
}
@@ -318,6 +322,15 @@ onMounted(load);
</div>
<div v-if="!isNew" class="field wide loyalty-box">
<p class="muted">باشگاه مشتریان</p>
<p>
{{ loyaltyPoints }} امتیاز
<span v-if="loyaltyTierName"> · سطح {{ loyaltyTierName }}</span>
</p>
<RouterLink :to="{ name: 'loyalty' }">مدیریت باشگاه</RouterLink>
</div>
</div>
+391
View File
@@ -0,0 +1,391 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import type {
ProjectBoardBoardDto,
ProjectBoardDto,
ProjectCardDto,
} from '@mento/shared';
import { canManageSettings } from '@/lib/roles';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const canCreateBoard = computed(() => canManageSettings(auth.user?.role));
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const board = ref<ProjectBoardBoardDto | null>(null);
const boards = ref<ProjectBoardDto[]>([]);
const draggingId = ref<string | null>(null);
const dropStageId = ref<string | null>(null);
const showCreate = ref(false);
const newName = ref('');
const selectedBoardId = computed(() => {
const q = route.query.boardId;
if (typeof q === 'string' && q) return q;
return board.value?.board.id ?? '';
});
const cardCount = computed(
() => board.value?.stages.reduce((n, s) => n + s.cards.length, 0) ?? 0,
);
async function loadBoards() {
const { data } = await api.get<ProjectBoardDto[]>('/project-boards');
boards.value = data;
}
async function load() {
loading.value = true;
error.value = '';
try {
await loadBoards();
const boardId =
typeof route.query.boardId === 'string' ? route.query.boardId : undefined;
const { data } = await api.get<ProjectBoardBoardDto>('/project-boards/board', {
params: boardId ? { boardId } : undefined,
});
board.value = data;
if (!boardId || boardId !== data.board.id) {
await router.replace({
name: 'projects',
query: { boardId: data.board.id },
});
}
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری برد پروژه ناموفق بود.';
}
} finally {
loading.value = false;
}
}
function onSelectBoard(event: Event) {
const id = (event.target as HTMLSelectElement).value;
void router.replace({ name: 'projects', query: { boardId: id } });
}
async function createBoard() {
const name = newName.value.trim();
if (name.length < 2 || saving.value) return;
saving.value = true;
error.value = '';
try {
const { data } = await api.post<ProjectBoardDto>('/project-boards', { name });
newName.value = '';
showCreate.value = false;
await router.replace({ name: 'projects', query: { boardId: data.id } });
} catch {
error.value = 'ساخت برد ناموفق بود.';
} finally {
saving.value = false;
}
}
function onDragStart(card: ProjectCardDto, event: DragEvent) {
draggingId.value = card.id;
event.dataTransfer?.setData('text/plain', card.id);
event.dataTransfer!.effectAllowed = 'move';
}
function onDragEnd() {
draggingId.value = null;
dropStageId.value = null;
}
function onDragOver(stageId: string, event: DragEvent) {
event.preventDefault();
dropStageId.value = stageId;
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
}
async function onDrop(stageId: string, event: DragEvent) {
event.preventDefault();
const cardId = event.dataTransfer?.getData('text/plain') || draggingId.value;
dropStageId.value = null;
draggingId.value = null;
if (!cardId || !board.value) return;
const current = board.value.stages.find((s) =>
s.cards.some((c) => c.id === cardId),
);
if (current?.id === stageId) return;
try {
await api.patch(`/project-cards/${cardId}/move`, { stageId });
await load();
} catch {
error.value = 'جابه‌جایی کارت ناموفق بود.';
}
}
function openCard(id: string) {
void router.push({ name: 'project-card-detail', params: { id } });
}
watch(
() => route.query.boardId,
(id, prev) => {
if (id === prev) return;
void load();
},
);
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<p class="eyebrow">پس‌ازفروش</p>
<h1>{{ board?.board.name || 'کاریز پروژه' }}</h1>
<p class="lead">
کارهای داخلی و تحویل را روی برد جابه‌جا کنید.
<span v-if="board">{{ cardCount }} کارت</span>
</p>
</div>
<div class="actions">
<button class="btn btn-ghost" type="button" :disabled="loading" @click="load">
بروزرسانی
</button>
<RouterLink
class="btn"
:to="{
name: 'project-card-new',
query: selectedBoardId ? { boardId: selectedBoardId } : undefined,
}"
>
کارت جدید
</RouterLink>
</div>
</header>
<div class="pipeline-bar">
<label class="picker">
<span>برد</span>
<select
:value="selectedBoardId"
:disabled="loading || !boards.length"
@change="onSelectBoard"
>
<option v-for="b in boards" :key="b.id" :value="b.id">
{{ b.name }}{{ b.isDefault ? ' (پیش‌فرض)' : '' }}
</option>
</select>
</label>
<button
v-if="canCreateBoard"
class="btn btn-ghost"
type="button"
@click="showCreate = !showCreate"
>
برد جدید
</button>
</div>
<form
v-if="showCreate && canCreateBoard"
class="create-box"
@submit.prevent="createBoard"
>
<input
v-model="newName"
type="text"
placeholder="نام برد جدید…"
maxlength="120"
required
minlength="2"
/>
<button class="btn" type="submit" :disabled="saving || newName.trim().length < 2">
{{ saving ? 'در حال ساخت…' : 'ساخت' }}
</button>
<button class="btn btn-ghost" type="button" @click="showCreate = false">انصراف</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading && !board" class="muted">در حال بارگذاری…</p>
<div v-if="board" class="board">
<div
v-for="stage in board.stages"
:key="stage.id"
class="column"
:class="{ drop: dropStageId === stage.id }"
@dragover="onDragOver(stage.id, $event)"
@dragleave="dropStageId = null"
@drop="onDrop(stage.id, $event)"
>
<div class="column-head">
<h2>{{ stage.name }}</h2>
<span>{{ stage.cards.length }}</span>
</div>
<div class="cards">
<article
v-for="card in stage.cards"
:key="card.id"
class="card"
:class="{ dragging: draggingId === card.id }"
draggable="true"
@dragstart="onDragStart(card, $event)"
@dragend="onDragEnd"
@dblclick="openCard(card.id)"
>
<h3 @click="openCard(card.id)">{{ card.title }}</h3>
<p v-if="card.person || card.company" class="meta">
<span v-if="card.person">{{ card.person.fullName }}</span>
<span v-if="card.person && card.company"> · </span>
<span v-if="card.company">{{ card.company.name }}</span>
</p>
<p v-if="card.deal" class="meta">معامله: {{ card.deal.title }}</p>
<p v-if="card.owner" class="meta">مسئول: {{ card.owner.fullName }}</p>
</article>
<p v-if="!stage.cards.length" class="empty">خالی</p>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
min-width: 0;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
flex-wrap: wrap;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0.2rem 0;
font-size: 1.85rem;
}
.lead,
.muted {
color: var(--muted);
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.pipeline-bar {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
align-items: center;
}
.picker {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1 1 200px;
min-width: 0;
color: var(--muted);
font-size: 0.9rem;
}
.picker select {
flex: 1;
min-width: 0;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.65rem 0.85rem;
background: #fff;
font: inherit;
}
.create-box {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.create-box input {
flex: 1 1 180px;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.65rem 0.85rem;
font: inherit;
}
.error {
color: #b42318;
}
.board {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(240px, 280px);
gap: 0.85rem;
overflow-x: auto;
padding-bottom: 0.5rem;
}
.column {
background: #f6f7f9;
border: 1px solid var(--line);
border-radius: 16px;
padding: 0.75rem;
min-height: 320px;
}
.column.drop {
outline: 2px dashed var(--accent);
}
.column-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.65rem;
}
.column-head h2 {
margin: 0;
font-size: 1rem;
}
.column-head span {
color: var(--muted);
font-size: 0.85rem;
}
.cards {
display: grid;
gap: 0.55rem;
}
.card {
background: #fff;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem 0.8rem;
cursor: grab;
}
.card.dragging {
opacity: 0.55;
}
.card h3 {
margin: 0 0 0.35rem;
font-size: 0.98rem;
cursor: pointer;
}
.meta {
margin: 0.15rem 0 0;
color: var(--muted);
font-size: 0.82rem;
}
.empty {
margin: 0.5rem 0 0;
color: var(--muted);
font-size: 0.85rem;
text-align: center;
}
</style>
+165
View File
@@ -0,0 +1,165 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import type { ProjectCardDto } from '@mento/shared';
const route = useRoute();
const router = useRouter();
const isNew = computed(() => route.name === 'project-card-new');
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const title = ref('');
const notes = ref('');
const cardId = ref('');
async function load() {
if (isNew.value) return;
const id = String(route.params.id || '');
if (!id) return;
loading.value = true;
error.value = '';
try {
const { data } = await api.get<ProjectCardDto>(`/project-cards/${id}`);
cardId.value = data.id;
title.value = data.title;
notes.value = data.notes ?? '';
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری کارت ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function save() {
if (title.value.trim().length < 2 || saving.value) return;
saving.value = true;
error.value = '';
try {
if (isNew.value) {
const boardId =
typeof route.query.boardId === 'string' ? route.query.boardId : undefined;
const { data } = await api.post<ProjectCardDto>('/project-cards', {
title: title.value.trim(),
notes: notes.value.trim() || undefined,
boardId,
});
await router.replace({ name: 'project-card-detail', params: { id: data.id } });
} else {
await api.patch(`/project-cards/${cardId.value}`, {
title: title.value.trim(),
notes: notes.value.trim() || null,
});
}
} catch {
error.value = 'ذخیره کارت ناموفق بود.';
} finally {
saving.value = false;
}
}
async function removeCard() {
if (isNew.value || !cardId.value) return;
if (!confirm('کارت حذف شود؟')) return;
try {
await api.delete(`/project-cards/${cardId.value}`);
await router.push({ name: 'projects' });
} catch {
error.value = 'حذف کارت ناموفق بود.';
}
}
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<RouterLink class="back" :to="{ name: 'projects' }">← بازگشت به برد</RouterLink>
<h1>{{ isNew ? 'کارت جدید' : 'ویرایش کارت' }}</h1>
</div>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<form v-else class="form" @submit.prevent="save">
<label>
عنوان
<input v-model="title" type="text" required minlength="2" maxlength="200" />
</label>
<label>
یادداشت
<textarea v-model="notes" rows="5" maxlength="4000" />
</label>
<div class="actions">
<button class="btn" type="submit" :disabled="saving || title.trim().length < 2">
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</button>
<button
v-if="!isNew"
class="btn btn-ghost danger"
type="button"
@click="removeCard"
>
حذف
</button>
</div>
</form>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
max-width: 640px;
}
.back {
color: var(--muted);
text-decoration: none;
font-size: 0.9rem;
}
h1 {
margin: 0.35rem 0 0;
}
.form {
display: grid;
gap: 0.85rem;
}
label {
display: grid;
gap: 0.35rem;
color: var(--muted);
font-size: 0.9rem;
}
input,
textarea {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem 0.85rem;
font: inherit;
color: var(--ink);
}
.actions {
display: flex;
gap: 0.5rem;
}
.error {
color: #b42318;
}
.muted {
color: var(--muted);
}
.danger {
color: #b42318;
}
</style>
+6 -5
View File
@@ -14,6 +14,7 @@
| تاریخ | مورد |
|---|---|
| ۱۴۰۵/۶/۸ | کاریز پروژه + باشگاه مشتریان + اتوماسیون اگر/آنگاه |
| ۱۴۰۵/۶/۷ | پرتال مشتری OTP + گزارشات و داشبورد |
| ۱۴۰۵/۶/۷ | SLA + FAQ + CSAT روی تیکت |
| ۱۴۰۵/۶/۷ | تیکتینگ پایه (لیست/فرم/گفتگو/ارجاع) |
@@ -102,8 +103,8 @@
| [x] | تیکتینگ | لیست/فرم، گفتگو، ارجاع، وضعیت و اولویت؛ ACL مسئول/ثبت‌کننده |
| [x] | پرتال مشتری OTP | ورود موبایل+کد؛ FAQ؛ تیکت/گفتگو/CSAT در `/p/:slug`؛ SMS ترِز مثل مونتایم |
| [x] | SLA + FAQ + CSAT | مهلت پاسخ اول، فیلتر تأخیر، FAQ در تنظیمات، رضایت ۱–۵ بعد از حل |
| [ ] | کاریز کارت / پروژه سبک |
| [ ] | باشگاه مشتریان ساده |
| [x] | کاریز کارت / پروژه سبک | برد جدا از فروش؛ مراحل صف/انجام/بازبینی/تمام؛ کارت با کشیدن |
| [x] | باشگاه مشتریان ساده | امتیاز روی شخص، سطوح برنزی/نقره‌ای/طلایی، تنظیم و تعدیل امتیاز |
---
@@ -112,7 +113,7 @@
| وضعیت | مورد |
|---|---|
| [x] | گزارشات و داشبوردها | داشبورد یک‌صفحه‌ای: فروش/قیف/فعالیت/تیم/تیکت با فیلتر تاریخ |
| [ ] | اتوماسیون اگر/آنگاه |
| [x] | اتوماسیون اگر/آنگاه | محرک deal_won / ticket_resolved → فعالیت، اعلان، یا امتیاز باشگاه |
| [ ] | API عمومی و وب‌هوک |
| [ ] | پنل پیامکی |
| [ ] | درگاه پرداخت |
@@ -135,8 +136,8 @@
ترتیب پیشنهادی برای ادامه:
1. کاریز کارت / پروژه سبک یا باشگاه مشتریان
2. اتوماسیون اگر/آنگاه
1. API عمومی و وب‌هوک
2. پنل پیامکی / درگاه پرداخت
3. Smart Notes AI / Memory وقتی کلید مدل مشخص شد
4. ارتقای ذخیره فایل به MinIO در صورت نیاز استقرار
---
+152
View File
@@ -162,6 +162,13 @@ export interface CreateCompanyRequest {
export type UpdateCompanyRequest = Partial<CreateCompanyRequest>;
export interface LoyaltyTierSummaryDto {
id: string;
name: string;
minPoints: number;
color: string | null;
}
export interface PersonDto {
id: string;
firstName: string;
@@ -173,6 +180,8 @@ export interface PersonDto {
position: string | null;
notes: string | null;
customFields: Record<string, unknown>;
loyaltyPoints: number;
loyaltyTier: LoyaltyTierSummaryDto | null;
company: CompanySummaryDto | null;
owner: UserSummaryDto | null;
createdAt: string;
@@ -520,6 +529,8 @@ export type NotificationType =
| "activity_overdue"
| "deal_moved"
| "note_reminder"
| "ticket_assigned"
| "automation"
| "system";
export interface NotificationDto {
@@ -1089,6 +1100,147 @@ export interface ReportsDashboardDto {
peopleCreated: number;
}
/** برد پروژه / کاریز کارت */
export interface ProjectBoardStageDto {
id: string;
name: string;
position: number;
}
export interface ProjectBoardDto {
id: string;
name: string;
isDefault: boolean;
stages: ProjectBoardStageDto[];
}
export interface ProjectCardDto {
id: string;
title: string;
notes: string | null;
position: number;
boardId: string;
stageId: string;
person: PersonSummaryDto | null;
company: CompanySummaryDto | null;
deal: { id: string; title: string } | null;
owner: UserSummaryDto | null;
createdAt: string;
updatedAt: string;
}
export interface ProjectBoardColumnDto extends ProjectBoardStageDto {
cards: ProjectCardDto[];
}
export interface ProjectBoardBoardDto {
board: ProjectBoardDto;
stages: ProjectBoardColumnDto[];
}
export interface CreateProjectBoardRequest {
name: string;
}
export interface CreateProjectCardRequest {
title: string;
boardId?: string;
stageId?: string;
notes?: string;
personId?: string | null;
companyId?: string | null;
dealId?: string | null;
ownerId?: string | null;
}
export type UpdateProjectCardRequest = Partial<CreateProjectCardRequest>;
export interface MoveProjectCardRequest {
stageId: string;
position?: number;
}
/** باشگاه مشتریان */
export interface LoyaltyTierDto {
id: string;
name: string;
minPoints: number;
position: number;
color: string | null;
memberCount?: number;
}
export interface CreateLoyaltyTierRequest {
name: string;
minPoints: number;
color?: string | null;
}
export type UpdateLoyaltyTierRequest = Partial<CreateLoyaltyTierRequest>;
export interface AdjustLoyaltyPointsRequest {
delta: number;
reason?: string;
}
export interface LoyaltyMemberDto {
id: string;
fullName: string;
mobile: string | null;
email: string | null;
loyaltyPoints: number;
loyaltyTier: LoyaltyTierSummaryDto | null;
company: CompanySummaryDto | null;
}
/** اتوماسیون اگر/آنگاه */
export type AutomationTrigger = "deal_won" | "ticket_resolved";
export type AutomationAction =
| "create_activity"
| "create_notification"
| "add_loyalty_points";
export const AUTOMATION_TRIGGER_LABELS: Record<AutomationTrigger, string> = {
deal_won: "معامله برنده شد",
ticket_resolved: "تیکت حل شد",
};
export const AUTOMATION_ACTION_LABELS: Record<AutomationAction, string> = {
create_activity: "ساخت فعالیت",
create_notification: "ارسال اعلان",
add_loyalty_points: "افزودن امتیاز باشگاه",
};
export interface AutomationRuleConfig {
title?: string;
body?: string;
assigneeId?: string | null;
notifyUserId?: string | null;
activityType?: "call" | "meeting" | "task" | "note";
points?: number;
}
export interface AutomationRuleDto {
id: string;
name: string;
isActive: boolean;
trigger: AutomationTrigger;
action: AutomationAction;
config: AutomationRuleConfig;
createdAt: string;
updatedAt: string;
}
export interface CreateAutomationRuleRequest {
name: string;
trigger: AutomationTrigger;
action: AutomationAction;
isActive?: boolean;
config?: AutomationRuleConfig;
}
export type UpdateAutomationRuleRequest = Partial<CreateAutomationRuleRequest>;
export const APP_NAME = "Mento CRM";
export const APP_NAME_FA = "منتو";
export const API_PREFIX = "api";