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:
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 در صورت نیاز استقرار
|
||||
---
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user