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
|
urgent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AutomationTrigger {
|
||||||
|
deal_won
|
||||||
|
ticket_resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AutomationAction {
|
||||||
|
create_activity
|
||||||
|
create_notification
|
||||||
|
add_loyalty_points
|
||||||
|
}
|
||||||
|
|
||||||
model Organization {
|
model Organization {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
@@ -119,6 +130,10 @@ model Organization {
|
|||||||
tickets Ticket[]
|
tickets Ticket[]
|
||||||
faqArticles FaqArticle[]
|
faqArticles FaqArticle[]
|
||||||
portalOtps PortalOtp[]
|
portalOtps PortalOtp[]
|
||||||
|
projectBoards ProjectBoard[]
|
||||||
|
projectCards ProjectCard[]
|
||||||
|
loyaltyTiers LoyaltyTier[]
|
||||||
|
automationRules AutomationRule[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -134,6 +149,7 @@ model User {
|
|||||||
ownedInvoices Invoice[] @relation("InvoiceOwner")
|
ownedInvoices Invoice[] @relation("InvoiceOwner")
|
||||||
ownedPeople Person[] @relation("PersonOwner")
|
ownedPeople Person[] @relation("PersonOwner")
|
||||||
ownedCompanies Company[] @relation("CompanyOwner")
|
ownedCompanies Company[] @relation("CompanyOwner")
|
||||||
|
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
|
||||||
activities Activity[] @relation("ActivityAssignee")
|
activities Activity[] @relation("ActivityAssignee")
|
||||||
notes Note[] @relation("NoteAuthor")
|
notes Note[] @relation("NoteAuthor")
|
||||||
noteAttachments NoteAttachment[] @relation("NoteAttachmentUploader")
|
noteAttachments NoteAttachment[] @relation("NoteAttachmentUploader")
|
||||||
@@ -170,6 +186,7 @@ model Company {
|
|||||||
ownerId String?
|
ownerId String?
|
||||||
owner User? @relation("CompanyOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
owner User? @relation("CompanyOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
||||||
tickets Ticket[]
|
tickets Ticket[]
|
||||||
|
projectCards ProjectCard[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -194,6 +211,9 @@ model Person {
|
|||||||
position String?
|
position String?
|
||||||
notes String?
|
notes String?
|
||||||
customFields Json @default("{}")
|
customFields Json @default("{}")
|
||||||
|
loyaltyPoints Int @default(0)
|
||||||
|
loyaltyTierId String?
|
||||||
|
loyaltyTier LoyaltyTier? @relation(fields: [loyaltyTierId], references: [id], onDelete: SetNull)
|
||||||
deals Deal[]
|
deals Deal[]
|
||||||
activities Activity[]
|
activities Activity[]
|
||||||
smartNotes Note[]
|
smartNotes Note[]
|
||||||
@@ -202,6 +222,7 @@ model Person {
|
|||||||
ownerId String?
|
ownerId String?
|
||||||
owner User? @relation("PersonOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
owner User? @relation("PersonOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
||||||
tickets Ticket[]
|
tickets Ticket[]
|
||||||
|
projectCards ProjectCard[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -209,8 +230,10 @@ model Person {
|
|||||||
@@index([organizationId, email])
|
@@index([organizationId, email])
|
||||||
@@index([organizationId, mobile])
|
@@index([organizationId, mobile])
|
||||||
@@index([organizationId, lastName, firstName])
|
@@index([organizationId, lastName, firstName])
|
||||||
|
@@index([organizationId, loyaltyPoints])
|
||||||
@@index([companyId])
|
@@index([companyId])
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
|
@@index([loyaltyTierId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Pipeline {
|
model Pipeline {
|
||||||
@@ -276,6 +299,7 @@ model Deal {
|
|||||||
quotes Quote[]
|
quotes Quote[]
|
||||||
invoices Invoice[]
|
invoices Invoice[]
|
||||||
tickets Ticket[]
|
tickets Ticket[]
|
||||||
|
projectCards ProjectCard[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -736,3 +760,95 @@ model PortalOtp {
|
|||||||
@@index([organizationId, mobile, createdAt])
|
@@index([organizationId, mobile, createdAt])
|
||||||
@@index([expiresAt])
|
@@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 { FaqModule } from './faq/faq.module';
|
||||||
import { PortalModule } from './portal/portal.module';
|
import { PortalModule } from './portal/portal.module';
|
||||||
import { ReportsModule } from './reports/reports.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -49,6 +52,9 @@ import { ReportsModule } from './reports/reports.module';
|
|||||||
FaqModule,
|
FaqModule,
|
||||||
PortalModule,
|
PortalModule,
|
||||||
ReportsModule,
|
ReportsModule,
|
||||||
|
ProjectBoardsModule,
|
||||||
|
LoyaltyModule,
|
||||||
|
AutomationsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
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 = {
|
const personInclude = {
|
||||||
company: { select: { id: true, name: true } },
|
company: { select: { id: true, name: true } },
|
||||||
owner: { select: { id: true, fullName: true } },
|
owner: { select: { id: true, fullName: true } },
|
||||||
|
loyaltyTier: {
|
||||||
|
select: { id: true, name: true, minPoints: true, color: true },
|
||||||
|
},
|
||||||
} satisfies Prisma.PersonInclude;
|
} satisfies Prisma.PersonInclude;
|
||||||
|
|
||||||
type PersonRow = {
|
type PersonRow = {
|
||||||
@@ -39,10 +42,17 @@ type PersonRow = {
|
|||||||
position: string | null;
|
position: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
customFields: unknown;
|
customFields: unknown;
|
||||||
|
loyaltyPoints: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
company: { id: string; name: string } | null;
|
company: { id: string; name: string } | null;
|
||||||
owner: { id: string; fullName: string } | null;
|
owner: { id: string; fullName: string } | null;
|
||||||
|
loyaltyTier: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
minPoints: number;
|
||||||
|
color: string | null;
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -229,6 +239,8 @@ export class PeopleService {
|
|||||||
position: row.position,
|
position: row.position,
|
||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
customFields: asCustomFields(row.customFields),
|
customFields: asCustomFields(row.customFields),
|
||||||
|
loyaltyPoints: row.loyaltyPoints ?? 0,
|
||||||
|
loyaltyTier: row.loyaltyTier,
|
||||||
company: row.company,
|
company: row.company,
|
||||||
owner: row.owner,
|
owner: row.owner,
|
||||||
createdAt: row.createdAt.toISOString(),
|
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 { PipelinesService } from './pipelines.service';
|
||||||
import { toJsonCustomFields } from '../common/custom-fields';
|
import { toJsonCustomFields } from '../common/custom-fields';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
import { AutomationsService } from '../automations/automations.service';
|
||||||
import {
|
import {
|
||||||
type Actor,
|
type Actor,
|
||||||
canReassignOwner,
|
canReassignOwner,
|
||||||
@@ -37,6 +38,7 @@ export class DealsService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly pipelines: PipelinesService,
|
private readonly pipelines: PipelinesService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly automations: AutomationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async get(
|
async get(
|
||||||
@@ -240,6 +242,9 @@ export class DealsService {
|
|||||||
pipelineId: true,
|
pipelineId: true,
|
||||||
stageId: true,
|
stageId: true,
|
||||||
ownerId: true,
|
ownerId: true,
|
||||||
|
status: true,
|
||||||
|
personId: true,
|
||||||
|
companyId: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!deal) throw new NotFoundException('معامله پیدا نشد');
|
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);
|
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 { DealsController } from './deals.controller';
|
||||||
import { DealsService } from './deals.service';
|
import { DealsService } from './deals.service';
|
||||||
import { PipelinesController } from './pipelines.controller';
|
import { PipelinesController } from './pipelines.controller';
|
||||||
import { PipelinesService } from './pipelines.service';
|
import { PipelinesService } from './pipelines.service';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { AutomationsModule } from '../automations/automations.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [NotificationsModule, AuthModule],
|
imports: [NotificationsModule, AuthModule, forwardRef(() => AutomationsModule)],
|
||||||
controllers: [PipelinesController, DealsController],
|
controllers: [PipelinesController, DealsController],
|
||||||
providers: [PipelinesService, DealsService],
|
providers: [PipelinesService, DealsService],
|
||||||
exports: [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 { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
import { AutomationsModule } from '../automations/automations.module';
|
||||||
import { TicketsController } from './tickets.controller';
|
import { TicketsController } from './tickets.controller';
|
||||||
import { TicketsService } from './tickets.service';
|
import { TicketsService } from './tickets.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [NotificationsModule],
|
imports: [NotificationsModule, forwardRef(() => AutomationsModule)],
|
||||||
controllers: [TicketsController],
|
controllers: [TicketsController],
|
||||||
providers: [TicketsService],
|
providers: [TicketsService],
|
||||||
exports: [TicketsService],
|
exports: [TicketsService],
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
ticketAccessWhere,
|
ticketAccessWhere,
|
||||||
} from '../common/record-scope';
|
} from '../common/record-scope';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
import { AutomationsService } from '../automations/automations.service';
|
||||||
|
|
||||||
const include = {
|
const include = {
|
||||||
person: { select: { id: true, firstName: true, lastName: true } },
|
person: { select: { id: true, firstName: true, lastName: true } },
|
||||||
@@ -40,6 +41,7 @@ export class TicketsService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly automations: AutomationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(
|
async list(
|
||||||
@@ -325,6 +327,25 @@ export class TicketsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.ticket.update({ where: { id }, data });
|
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);
|
return this.get(actor, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -190,6 +190,8 @@ onUnmounted(() => {
|
|||||||
<RouterLink to="/activities">فعالیتها</RouterLink>
|
<RouterLink to="/activities">فعالیتها</RouterLink>
|
||||||
<RouterLink to="/activities/calendar">تقویم فعالیتها</RouterLink>
|
<RouterLink to="/activities/calendar">تقویم فعالیتها</RouterLink>
|
||||||
<RouterLink to="/pipeline">کاریز فروش</RouterLink>
|
<RouterLink to="/pipeline">کاریز فروش</RouterLink>
|
||||||
|
<RouterLink to="/projects">کاریز پروژه</RouterLink>
|
||||||
|
<RouterLink to="/loyalty">باشگاه مشتریان</RouterLink>
|
||||||
<RouterLink to="/sales/forecast">هدف و پیشبینی</RouterLink>
|
<RouterLink to="/sales/forecast">هدف و پیشبینی</RouterLink>
|
||||||
<RouterLink to="/reports">گزارشات</RouterLink>
|
<RouterLink to="/reports">گزارشات</RouterLink>
|
||||||
<RouterLink to="/tickets">تیکتها</RouterLink>
|
<RouterLink to="/tickets">تیکتها</RouterLink>
|
||||||
@@ -199,6 +201,7 @@ onUnmounted(() => {
|
|||||||
<RouterLink to="/notes">یادداشتها</RouterLink>
|
<RouterLink to="/notes">یادداشتها</RouterLink>
|
||||||
<template v-if="canSettings">
|
<template v-if="canSettings">
|
||||||
<RouterLink to="/settings/team">تیم و نقشها</RouterLink>
|
<RouterLink to="/settings/team">تیم و نقشها</RouterLink>
|
||||||
|
<RouterLink to="/settings/automations">اتوماسیون</RouterLink>
|
||||||
<RouterLink to="/settings/lead-forms">فرمساز سرنخ</RouterLink>
|
<RouterLink to="/settings/lead-forms">فرمساز سرنخ</RouterLink>
|
||||||
<RouterLink to="/settings/custom-fields">فیلدهای سفارشی</RouterLink>
|
<RouterLink to="/settings/custom-fields">فیلدهای سفارشی</RouterLink>
|
||||||
<RouterLink to="/settings/products">محصولات</RouterLink>
|
<RouterLink to="/settings/products">محصولات</RouterLink>
|
||||||
|
|||||||
@@ -118,6 +118,26 @@ export const router = createRouter({
|
|||||||
name: 'pipeline',
|
name: 'pipeline',
|
||||||
component: () => import('@/views/PipelineBoardView.vue'),
|
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',
|
path: 'deals/new',
|
||||||
name: 'deal-new',
|
name: 'deal-new',
|
||||||
@@ -138,6 +158,12 @@ export const router = createRouter({
|
|||||||
name: 'notes',
|
name: 'notes',
|
||||||
component: () => import('@/views/NotesView.vue'),
|
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',
|
path: 'settings/lead-forms',
|
||||||
name: '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 companyId = ref('');
|
||||||
|
|
||||||
const customFields = ref<Record<string, unknown>>({});
|
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 ?? '';
|
companyId.value = p.company?.id ?? '';
|
||||||
customFields.value = { ...(p.customFields ?? {}) };
|
customFields.value = { ...(p.customFields ?? {}) };
|
||||||
|
loyaltyPoints.value = p.loyaltyPoints ?? 0;
|
||||||
|
loyaltyTierName.value = p.loyaltyTier?.name ?? '';
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,6 +322,15 @@ onMounted(load);
|
|||||||
|
|
||||||
</div>
|
</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>
|
</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 + گزارشات و داشبورد |
|
| ۱۴۰۵/۶/۷ | پرتال مشتری OTP + گزارشات و داشبورد |
|
||||||
| ۱۴۰۵/۶/۷ | SLA + FAQ + CSAT روی تیکت |
|
| ۱۴۰۵/۶/۷ | SLA + FAQ + CSAT روی تیکت |
|
||||||
| ۱۴۰۵/۶/۷ | تیکتینگ پایه (لیست/فرم/گفتگو/ارجاع) |
|
| ۱۴۰۵/۶/۷ | تیکتینگ پایه (لیست/فرم/گفتگو/ارجاع) |
|
||||||
@@ -102,8 +103,8 @@
|
|||||||
| [x] | تیکتینگ | لیست/فرم، گفتگو، ارجاع، وضعیت و اولویت؛ ACL مسئول/ثبتکننده |
|
| [x] | تیکتینگ | لیست/فرم، گفتگو، ارجاع، وضعیت و اولویت؛ ACL مسئول/ثبتکننده |
|
||||||
| [x] | پرتال مشتری OTP | ورود موبایل+کد؛ FAQ؛ تیکت/گفتگو/CSAT در `/p/:slug`؛ SMS ترِز مثل مونتایم |
|
| [x] | پرتال مشتری OTP | ورود موبایل+کد؛ FAQ؛ تیکت/گفتگو/CSAT در `/p/:slug`؛ SMS ترِز مثل مونتایم |
|
||||||
| [x] | SLA + FAQ + CSAT | مهلت پاسخ اول، فیلتر تأخیر، FAQ در تنظیمات، رضایت ۱–۵ بعد از حل |
|
| [x] | SLA + FAQ + CSAT | مهلت پاسخ اول، فیلتر تأخیر، FAQ در تنظیمات، رضایت ۱–۵ بعد از حل |
|
||||||
| [ ] | کاریز کارت / پروژه سبک |
|
| [x] | کاریز کارت / پروژه سبک | برد جدا از فروش؛ مراحل صف/انجام/بازبینی/تمام؛ کارت با کشیدن |
|
||||||
| [ ] | باشگاه مشتریان ساده |
|
| [x] | باشگاه مشتریان ساده | امتیاز روی شخص، سطوح برنزی/نقرهای/طلایی، تنظیم و تعدیل امتیاز |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -112,7 +113,7 @@
|
|||||||
| وضعیت | مورد |
|
| وضعیت | مورد |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [x] | گزارشات و داشبوردها | داشبورد یکصفحهای: فروش/قیف/فعالیت/تیم/تیکت با فیلتر تاریخ |
|
| [x] | گزارشات و داشبوردها | داشبورد یکصفحهای: فروش/قیف/فعالیت/تیم/تیکت با فیلتر تاریخ |
|
||||||
| [ ] | اتوماسیون اگر/آنگاه |
|
| [x] | اتوماسیون اگر/آنگاه | محرک deal_won / ticket_resolved → فعالیت، اعلان، یا امتیاز باشگاه |
|
||||||
| [ ] | API عمومی و وبهوک |
|
| [ ] | API عمومی و وبهوک |
|
||||||
| [ ] | پنل پیامکی |
|
| [ ] | پنل پیامکی |
|
||||||
| [ ] | درگاه پرداخت |
|
| [ ] | درگاه پرداخت |
|
||||||
@@ -135,8 +136,8 @@
|
|||||||
|
|
||||||
ترتیب پیشنهادی برای ادامه:
|
ترتیب پیشنهادی برای ادامه:
|
||||||
|
|
||||||
1. کاریز کارت / پروژه سبک یا باشگاه مشتریان
|
1. API عمومی و وبهوک
|
||||||
2. اتوماسیون اگر/آنگاه
|
2. پنل پیامکی / درگاه پرداخت
|
||||||
3. Smart Notes AI / Memory وقتی کلید مدل مشخص شد
|
3. Smart Notes AI / Memory وقتی کلید مدل مشخص شد
|
||||||
4. ارتقای ذخیره فایل به MinIO در صورت نیاز استقرار
|
4. ارتقای ذخیره فایل به MinIO در صورت نیاز استقرار
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -162,6 +162,13 @@ export interface CreateCompanyRequest {
|
|||||||
|
|
||||||
export type UpdateCompanyRequest = Partial<CreateCompanyRequest>;
|
export type UpdateCompanyRequest = Partial<CreateCompanyRequest>;
|
||||||
|
|
||||||
|
export interface LoyaltyTierSummaryDto {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
minPoints: number;
|
||||||
|
color: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PersonDto {
|
export interface PersonDto {
|
||||||
id: string;
|
id: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
@@ -173,6 +180,8 @@ export interface PersonDto {
|
|||||||
position: string | null;
|
position: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
customFields: Record<string, unknown>;
|
customFields: Record<string, unknown>;
|
||||||
|
loyaltyPoints: number;
|
||||||
|
loyaltyTier: LoyaltyTierSummaryDto | null;
|
||||||
company: CompanySummaryDto | null;
|
company: CompanySummaryDto | null;
|
||||||
owner: UserSummaryDto | null;
|
owner: UserSummaryDto | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -520,6 +529,8 @@ export type NotificationType =
|
|||||||
| "activity_overdue"
|
| "activity_overdue"
|
||||||
| "deal_moved"
|
| "deal_moved"
|
||||||
| "note_reminder"
|
| "note_reminder"
|
||||||
|
| "ticket_assigned"
|
||||||
|
| "automation"
|
||||||
| "system";
|
| "system";
|
||||||
|
|
||||||
export interface NotificationDto {
|
export interface NotificationDto {
|
||||||
@@ -1089,6 +1100,147 @@ export interface ReportsDashboardDto {
|
|||||||
peopleCreated: number;
|
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 = "Mento CRM";
|
||||||
export const APP_NAME_FA = "منتو";
|
export const APP_NAME_FA = "منتو";
|
||||||
export const API_PREFIX = "api";
|
export const API_PREFIX = "api";
|
||||||
|
|||||||
Reference in New Issue
Block a user