Let automations fire on more CRM events and open the studio from the main nav.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
CREATE TYPE "AutomationTrigger_new" AS ENUM (
|
||||
'person_created',
|
||||
'lead_submitted',
|
||||
'deal_created',
|
||||
'deal_stage_changed',
|
||||
'deal_won',
|
||||
'deal_lost',
|
||||
'ticket_created',
|
||||
'ticket_resolved',
|
||||
'invoice_paid'
|
||||
);
|
||||
|
||||
ALTER TABLE "AutomationRule"
|
||||
ALTER COLUMN "trigger" TYPE "AutomationTrigger_new"
|
||||
USING ("trigger"::text::"AutomationTrigger_new");
|
||||
|
||||
ALTER TYPE "AutomationTrigger" RENAME TO "AutomationTrigger_old";
|
||||
ALTER TYPE "AutomationTrigger_new" RENAME TO "AutomationTrigger";
|
||||
DROP TYPE "AutomationTrigger_old";
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
CREATE TYPE "AutomationTrigger_new" AS ENUM (
|
||||
'person_created',
|
||||
'lead_submitted',
|
||||
'deal_created',
|
||||
'deal_stage_changed',
|
||||
'deal_won',
|
||||
'deal_lost',
|
||||
'ticket_created',
|
||||
'ticket_resolved',
|
||||
'invoice_issued',
|
||||
'invoice_paid',
|
||||
'activity_done'
|
||||
);
|
||||
|
||||
ALTER TABLE "AutomationRule"
|
||||
ALTER COLUMN "trigger" TYPE "AutomationTrigger_new"
|
||||
USING ("trigger"::text::"AutomationTrigger_new");
|
||||
|
||||
ALTER TYPE "AutomationTrigger" RENAME TO "AutomationTrigger_old";
|
||||
ALTER TYPE "AutomationTrigger_new" RENAME TO "AutomationTrigger";
|
||||
DROP TYPE "AutomationTrigger_old";
|
||||
@@ -108,8 +108,17 @@ enum TicketPriority {
|
||||
}
|
||||
|
||||
enum AutomationTrigger {
|
||||
person_created
|
||||
lead_submitted
|
||||
deal_created
|
||||
deal_stage_changed
|
||||
deal_won
|
||||
deal_lost
|
||||
ticket_created
|
||||
ticket_resolved
|
||||
invoice_issued
|
||||
invoice_paid
|
||||
activity_done
|
||||
}
|
||||
|
||||
enum AutomationAction {
|
||||
|
||||
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
||||
import { ActivitiesController } from './activities.controller';
|
||||
import { ActivitiesService } from './activities.service';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { AutomationsModule } from '../automations/automations.module';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationsModule],
|
||||
imports: [NotificationsModule, AutomationsModule],
|
||||
controllers: [ActivitiesController],
|
||||
providers: [ActivitiesService],
|
||||
exports: [ActivitiesService],
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
import { type Actor, assigneeWhere, ownerWhere } from '../common/record-scope';
|
||||
|
||||
type ActivityActor = Pick<Actor, 'userId' | 'role'>;
|
||||
@@ -53,6 +54,7 @@ export class ActivitiesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly automations: AutomationsService,
|
||||
) {}
|
||||
|
||||
async summary(
|
||||
@@ -245,11 +247,18 @@ export class ActivitiesService {
|
||||
dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined;
|
||||
}
|
||||
|
||||
const existing = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { status: true },
|
||||
});
|
||||
const row = await this.prisma.activity.update({
|
||||
where: { id },
|
||||
data,
|
||||
include,
|
||||
});
|
||||
if (dto.status === 'done' && existing?.status !== 'done') {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
@@ -259,11 +268,18 @@ export class ActivitiesService {
|
||||
actor: ActivityActor,
|
||||
): Promise<ActivityDto> {
|
||||
await this.ensureExists(organizationId, id, actor);
|
||||
const existing = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { status: true },
|
||||
});
|
||||
const row = await this.prisma.activity.update({
|
||||
where: { id },
|
||||
data: { status: 'done', completedAt: new Date() },
|
||||
include,
|
||||
});
|
||||
if (existing?.status !== 'done') {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
@@ -350,6 +366,30 @@ export class ActivitiesService {
|
||||
}
|
||||
}
|
||||
|
||||
private async fireDone(
|
||||
organizationId: string,
|
||||
row: {
|
||||
id: string;
|
||||
title: string;
|
||||
person: { id: string } | null;
|
||||
company: { id: string } | null;
|
||||
deal: { id: string } | null;
|
||||
assignee: { id: string } | null;
|
||||
},
|
||||
) {
|
||||
await this.automations.runTrigger('activity_done', {
|
||||
organizationId,
|
||||
entityType: 'activity',
|
||||
entityId: row.id,
|
||||
title: row.title,
|
||||
personId: row.person?.id ?? null,
|
||||
companyId: row.company?.id ?? null,
|
||||
dealId: row.deal?.id ?? null,
|
||||
assigneeId: row.assignee?.id ?? null,
|
||||
ownerId: row.assignee?.id ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureExists(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
|
||||
@@ -32,7 +32,7 @@ import { SmsService } from '../common/sms.service';
|
||||
|
||||
export type AutomationContext = {
|
||||
organizationId: string;
|
||||
entityType: 'deal' | 'ticket';
|
||||
entityType: 'deal' | 'ticket' | 'person' | 'invoice' | 'activity';
|
||||
entityId: string;
|
||||
title: string;
|
||||
personId?: string | null;
|
||||
@@ -182,15 +182,65 @@ export class AutomationsService {
|
||||
ticketPriority: null,
|
||||
};
|
||||
}
|
||||
const ticket = await this.prisma.ticket.findFirst({
|
||||
where: { id: ctx.entityId, organizationId: ctx.organizationId },
|
||||
select: { priority: true, personId: true, companyId: true },
|
||||
});
|
||||
if (ctx.entityType === 'ticket') {
|
||||
const ticket = await this.prisma.ticket.findFirst({
|
||||
where: { id: ctx.entityId, organizationId: ctx.organizationId },
|
||||
select: { priority: true, personId: true, companyId: true },
|
||||
});
|
||||
return {
|
||||
amount: null,
|
||||
hasPerson: Boolean(ticket?.personId ?? ctx.personId),
|
||||
hasCompany: Boolean(ticket?.companyId ?? ctx.companyId),
|
||||
ticketPriority: ticket?.priority ?? null,
|
||||
};
|
||||
}
|
||||
if (ctx.entityType === 'invoice') {
|
||||
const invoice = await this.prisma.invoice.findFirst({
|
||||
where: { id: ctx.entityId, organizationId: ctx.organizationId },
|
||||
select: {
|
||||
personId: true,
|
||||
companyId: true,
|
||||
discountAmount: true,
|
||||
taxPercent: true,
|
||||
lineItems: { select: { quantity: true, unitPrice: true } },
|
||||
},
|
||||
});
|
||||
let amount: number | null = null;
|
||||
if (invoice) {
|
||||
const subtotal = invoice.lineItems.reduce(
|
||||
(sum, item) => sum + Number(item.quantity) * Number(item.unitPrice),
|
||||
0,
|
||||
);
|
||||
const discount = Number(invoice.discountAmount ?? 0);
|
||||
const taxPercent = Number(invoice.taxPercent ?? 0);
|
||||
amount = Math.abs(
|
||||
subtotal - discount + Math.max(0, subtotal - discount) * (taxPercent / 100),
|
||||
);
|
||||
}
|
||||
return {
|
||||
amount,
|
||||
hasPerson: Boolean(invoice?.personId ?? ctx.personId),
|
||||
hasCompany: Boolean(invoice?.companyId ?? ctx.companyId),
|
||||
ticketPriority: null,
|
||||
};
|
||||
}
|
||||
if (ctx.entityType === 'activity') {
|
||||
const activity = await this.prisma.activity.findFirst({
|
||||
where: { id: ctx.entityId, organizationId: ctx.organizationId },
|
||||
select: { personId: true, companyId: true, dealId: true },
|
||||
});
|
||||
return {
|
||||
amount: null,
|
||||
hasPerson: Boolean(activity?.personId ?? ctx.personId),
|
||||
hasCompany: Boolean(activity?.companyId ?? ctx.companyId),
|
||||
ticketPriority: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
amount: null,
|
||||
hasPerson: Boolean(ticket?.personId ?? ctx.personId),
|
||||
hasCompany: Boolean(ticket?.companyId ?? ctx.companyId),
|
||||
ticketPriority: ticket?.priority ?? null,
|
||||
hasPerson: Boolean(ctx.personId),
|
||||
hasCompany: Boolean(ctx.companyId),
|
||||
ticketPriority: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -227,10 +277,7 @@ export class AutomationsService {
|
||||
type: 'automation',
|
||||
title: 'فعالیت خودکار',
|
||||
body: title,
|
||||
link:
|
||||
ctx.entityType === 'deal'
|
||||
? `/deals/${ctx.entityId}`
|
||||
: `/tickets/${ctx.entityId}`,
|
||||
link: entityLink(ctx),
|
||||
entityType: ctx.entityType,
|
||||
entityId: ctx.entityId,
|
||||
});
|
||||
@@ -263,10 +310,7 @@ export class AutomationsService {
|
||||
const wantApp = channel === 'app' || channel === 'both';
|
||||
const wantSms = channel === 'sms' || channel === 'both';
|
||||
const smsText = (config.body?.trim() || title).slice(0, 160);
|
||||
const link =
|
||||
ctx.entityType === 'deal'
|
||||
? `/deals/${ctx.entityId}`
|
||||
: `/tickets/${ctx.entityId}`;
|
||||
const link = entityLink(ctx);
|
||||
|
||||
if (config.notifyTarget === 'person') {
|
||||
if (wantSms) {
|
||||
@@ -413,3 +457,12 @@ function defaultTitle(action: AutomationAction, entityTitle: string) {
|
||||
if (action === 'create_notification') return `اتوماسیون: ${entityTitle}`;
|
||||
return entityTitle;
|
||||
}
|
||||
|
||||
function entityLink(ctx: AutomationContext) {
|
||||
if (ctx.entityType === 'deal') return `/deals/${ctx.entityId}`;
|
||||
if (ctx.entityType === 'ticket') return `/tickets/${ctx.entityId}`;
|
||||
if (ctx.entityType === 'invoice') return `/invoices/${ctx.entityId}`;
|
||||
if (ctx.entityType === 'person') return `/contacts/people/${ctx.entityId}`;
|
||||
if (ctx.entityType === 'activity') return `/activities/${ctx.entityId}`;
|
||||
return '/';
|
||||
}
|
||||
|
||||
@@ -12,7 +12,19 @@ import {
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
const TRIGGERS = ['deal_won', 'ticket_resolved'] as const;
|
||||
const TRIGGERS = [
|
||||
'person_created',
|
||||
'lead_submitted',
|
||||
'deal_created',
|
||||
'deal_stage_changed',
|
||||
'deal_won',
|
||||
'deal_lost',
|
||||
'ticket_created',
|
||||
'ticket_resolved',
|
||||
'invoice_issued',
|
||||
'invoice_paid',
|
||||
'activity_done',
|
||||
] as const;
|
||||
const ACTIONS = [
|
||||
'create_activity',
|
||||
'create_notification',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LoyaltyModule } from '../loyalty/loyalty.module';
|
||||
import { AutomationsModule } from '../automations/automations.module';
|
||||
import { CompaniesController } from './companies.controller';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { PeopleController } from './people.controller';
|
||||
@@ -7,7 +8,7 @@ import { PeopleService } from './people.service';
|
||||
import { ContactsExcelService } from './contacts-excel.service';
|
||||
|
||||
@Module({
|
||||
imports: [LoyaltyModule],
|
||||
imports: [LoyaltyModule, AutomationsModule],
|
||||
controllers: [PeopleController, CompaniesController],
|
||||
providers: [PeopleService, CompaniesService, ContactsExcelService],
|
||||
exports: [PeopleService, CompaniesService],
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
resolveOwnerId,
|
||||
} from '../common/record-scope';
|
||||
import { LoyaltyService } from '../loyalty/loyalty.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
|
||||
const personInclude = {
|
||||
company: { select: { id: true, name: true } },
|
||||
@@ -64,6 +65,7 @@ export class PeopleService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly companies: CompaniesService,
|
||||
private readonly loyalty: LoyaltyService,
|
||||
private readonly automations: AutomationsService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
@@ -149,6 +151,15 @@ export class PeopleService {
|
||||
include: personInclude,
|
||||
});
|
||||
await this.loyalty.applyWelcomePoints(actor.organizationId, row.id);
|
||||
await this.automations.runTrigger('person_created', {
|
||||
organizationId: actor.organizationId,
|
||||
entityType: 'person',
|
||||
entityId: row.id,
|
||||
title: `${row.firstName} ${row.lastName}`.trim(),
|
||||
personId: row.id,
|
||||
companyId: row.companyId,
|
||||
ownerId: row.ownerId,
|
||||
});
|
||||
const fresh = await this.prisma.person.findFirst({
|
||||
where: { id: row.id },
|
||||
include: personInclude,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AutomationsModule } from '../automations/automations.module';
|
||||
import { InvoicesController } from './invoices.controller';
|
||||
import { InvoicesService } from './invoices.service';
|
||||
|
||||
@Module({
|
||||
imports: [AutomationsModule],
|
||||
controllers: [InvoicesController],
|
||||
providers: [InvoicesService],
|
||||
exports: [InvoicesService],
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { type Actor, ownerWhere } from '../common/record-scope';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
|
||||
type InvoiceActor = Pick<Actor, 'userId' | 'role'>;
|
||||
|
||||
@@ -37,7 +38,10 @@ type InvoiceRow = Prisma.InvoiceGetPayload<{ include: typeof include }>;
|
||||
|
||||
@Injectable()
|
||||
export class InvoicesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly automations: AutomationsService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
organizationId: string,
|
||||
@@ -264,7 +268,11 @@ export class InvoicesService {
|
||||
},
|
||||
include,
|
||||
});
|
||||
return this.toDto(row);
|
||||
const created = this.toDto(row);
|
||||
if (created.status !== 'draft' && created.status !== 'canceled') {
|
||||
await this.fireIssued(organizationId, created);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(
|
||||
@@ -374,7 +382,15 @@ export class InvoicesService {
|
||||
await this.recomputeStatus(id);
|
||||
}
|
||||
|
||||
return this.get(organizationId, id, actor);
|
||||
const row = await this.get(organizationId, id, actor);
|
||||
const leftDraft =
|
||||
existing.status === 'draft' &&
|
||||
row.status !== 'draft' &&
|
||||
row.status !== 'canceled';
|
||||
if (leftDraft) {
|
||||
await this.fireIssued(organizationId, row);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
async remove(
|
||||
@@ -443,7 +459,21 @@ export class InvoicesService {
|
||||
});
|
||||
|
||||
await this.recomputeStatus(invoiceId);
|
||||
return this.get(organizationId, invoiceId, actor);
|
||||
const row = await this.get(organizationId, invoiceId, actor);
|
||||
if (invoice.status === 'draft') {
|
||||
await this.fireIssued(organizationId, row);
|
||||
}
|
||||
await this.automations.runTrigger('invoice_paid', {
|
||||
organizationId,
|
||||
entityType: 'invoice',
|
||||
entityId: row.id,
|
||||
title: row.title || row.number,
|
||||
personId: row.person?.id ?? null,
|
||||
companyId: row.company?.id ?? null,
|
||||
dealId: row.deal?.id ?? null,
|
||||
ownerId: row.owner?.id ?? null,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
async removePayment(
|
||||
@@ -549,6 +579,19 @@ export class InvoicesService {
|
||||
return `FA-${String(count + 1).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
private async fireIssued(organizationId: string, row: InvoiceDto) {
|
||||
await this.automations.runTrigger('invoice_issued', {
|
||||
organizationId,
|
||||
entityType: 'invoice',
|
||||
entityId: row.id,
|
||||
title: row.title || row.number,
|
||||
personId: row.person?.id ?? null,
|
||||
companyId: row.company?.id ?? null,
|
||||
dealId: row.deal?.id ?? null,
|
||||
ownerId: row.owner?.id ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureExists(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { AutomationsModule } from '../automations/automations.module';
|
||||
import { LeadFormsController } from './lead-forms.controller';
|
||||
import { LeadFormsService } from './lead-forms.service';
|
||||
import { PublicLeadFormsController } from './public-lead-forms.controller';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
imports: [AuthModule, AutomationsModule],
|
||||
controllers: [LeadFormsController, PublicLeadFormsController],
|
||||
providers: [LeadFormsService],
|
||||
exports: [LeadFormsService],
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
import {
|
||||
emptyToNull,
|
||||
normalizeEmail,
|
||||
@@ -66,6 +67,7 @@ export class LeadFormsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly files: LocalFileStorage,
|
||||
private readonly automations: AutomationsService,
|
||||
) {}
|
||||
|
||||
async list(organizationId: string): Promise<LeadFormDto[]> {
|
||||
@@ -275,6 +277,19 @@ export class LeadFormsService {
|
||||
});
|
||||
}
|
||||
|
||||
const personName = [mapped.firstName, mapped.lastName]
|
||||
.filter((p) => p?.trim())
|
||||
.join(' ')
|
||||
.trim();
|
||||
await this.automations.runTrigger('lead_submitted', {
|
||||
organizationId: form.organizationId,
|
||||
entityType: 'person',
|
||||
entityId: personId ?? form.id,
|
||||
title: personName || form.name,
|
||||
personId,
|
||||
ownerId: ownerId ?? actorId,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
thankYouMessage: form.thankYouMessage,
|
||||
|
||||
@@ -147,6 +147,17 @@ export class DealsService {
|
||||
include: dealInclude,
|
||||
});
|
||||
|
||||
await this.automations.runTrigger('deal_created', {
|
||||
organizationId,
|
||||
entityType: 'deal',
|
||||
entityId: created.id,
|
||||
title: created.title,
|
||||
personId: created.personId,
|
||||
companyId: created.companyId,
|
||||
dealId: created.id,
|
||||
ownerId: created.ownerId,
|
||||
});
|
||||
|
||||
return this.pipelines.toDealDto(created);
|
||||
}
|
||||
|
||||
@@ -290,17 +301,24 @@ export class DealsService {
|
||||
});
|
||||
}
|
||||
|
||||
if (status === 'won' && deal.status !== 'won') {
|
||||
await this.automations.runTrigger('deal_won', {
|
||||
if (deal.stageId !== stage.id) {
|
||||
const ctx = {
|
||||
organizationId,
|
||||
entityType: 'deal',
|
||||
entityType: 'deal' as const,
|
||||
entityId: deal.id,
|
||||
title: deal.title,
|
||||
personId: deal.personId,
|
||||
companyId: deal.companyId,
|
||||
dealId: deal.id,
|
||||
ownerId: deal.ownerId,
|
||||
});
|
||||
};
|
||||
await this.automations.runTrigger('deal_stage_changed', ctx);
|
||||
if (status === 'won' && deal.status !== 'won') {
|
||||
await this.automations.runTrigger('deal_won', ctx);
|
||||
}
|
||||
if (status === 'lost' && deal.status !== 'lost') {
|
||||
await this.automations.runTrigger('deal_lost', ctx);
|
||||
}
|
||||
}
|
||||
|
||||
return this.pipelines.toDealDto(row);
|
||||
|
||||
@@ -228,6 +228,18 @@ export class TicketsService {
|
||||
});
|
||||
}
|
||||
|
||||
await this.automations.runTrigger('ticket_created', {
|
||||
organizationId: actor.organizationId,
|
||||
entityType: 'ticket',
|
||||
entityId: row.id,
|
||||
title: row.subject,
|
||||
personId: row.personId,
|
||||
companyId: row.companyId,
|
||||
dealId: row.dealId,
|
||||
assigneeId: row.assigneeId,
|
||||
ownerId: row.assigneeId,
|
||||
});
|
||||
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
|
||||
@@ -221,6 +221,8 @@ function nodeClass(step: AutomationStep) {
|
||||
<style scoped>
|
||||
.canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 22rem;
|
||||
border-radius: 16px;
|
||||
overflow: auto;
|
||||
|
||||
@@ -17,8 +17,8 @@ import {
|
||||
AUTOMATION_CONDITION_OP_LABELS,
|
||||
AUTOMATION_NOTIFY_CHANNEL_LABELS,
|
||||
AUTOMATION_NOTIFY_CHANNELS,
|
||||
AUTOMATION_TRIGGER_GROUPS,
|
||||
AUTOMATION_TRIGGER_LABELS,
|
||||
AUTOMATION_TRIGGERS,
|
||||
conditionFieldsForTrigger,
|
||||
isAutomationCondition,
|
||||
} from '@mento/shared';
|
||||
@@ -104,9 +104,15 @@ watch(
|
||||
<label>
|
||||
اگر
|
||||
<select v-model="trigger">
|
||||
<option v-for="t in AUTOMATION_TRIGGERS" :key="t" :value="t">
|
||||
{{ AUTOMATION_TRIGGER_LABELS[t] }}
|
||||
</option>
|
||||
<optgroup
|
||||
v-for="group in AUTOMATION_TRIGGER_GROUPS"
|
||||
:key="group.label"
|
||||
:label="group.label"
|
||||
>
|
||||
<option v-for="t in group.items" :key="t" :value="t">
|
||||
{{ AUTOMATION_TRIGGER_LABELS[t] }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
@@ -40,7 +40,6 @@ const salesPaths = ['/quotes', '/invoices', '/sales/forecast', '/reports', '/not
|
||||
const morePaths = ['/activities/calendar', '/projects', '/project-cards', '/loyalty'];
|
||||
const settingsPaths = [
|
||||
'/settings/team',
|
||||
'/settings/automations',
|
||||
'/settings/lead-forms',
|
||||
'/settings/custom-fields',
|
||||
'/settings/products',
|
||||
@@ -59,6 +58,7 @@ const salesActive = computed(() => pathIn(salesPaths));
|
||||
const moreActive = computed(() => pathIn(morePaths));
|
||||
const settingsActive = computed(() => pathIn(settingsPaths));
|
||||
const productsActive = computed(() => route.path.startsWith('/settings/products'));
|
||||
const automationsActive = computed(() => route.path.startsWith('/settings/automations'));
|
||||
const extraActive = computed(() =>
|
||||
pathIn([
|
||||
'/quotes',
|
||||
@@ -66,7 +66,6 @@ const extraActive = computed(() =>
|
||||
'/sales/forecast',
|
||||
'/notes',
|
||||
'/tickets',
|
||||
'/settings/automations',
|
||||
'/settings/lead-forms',
|
||||
'/settings/custom-fields',
|
||||
'/settings/currencies',
|
||||
@@ -348,6 +347,17 @@ onUnmounted(() => {
|
||||
<UiIcon name="settings" :size="collapsed ? 20 : 18" />
|
||||
<span>تنظیمات</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="canSettings"
|
||||
to="/settings/automations"
|
||||
class="nav-item"
|
||||
title="اتوماسیون"
|
||||
:class="{ 'router-link-active': automationsActive }"
|
||||
@click="closeMenu"
|
||||
>
|
||||
<UiIcon name="layers" :size="collapsed ? 20 : 18" />
|
||||
<span>اتوماسیون</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div v-if="collapsed" ref="moreBtnRef" class="nav-more desktop-only">
|
||||
@@ -419,7 +429,6 @@ onUnmounted(() => {
|
||||
</button>
|
||||
<div v-show="settingsOpen" class="nav-sub">
|
||||
<RouterLink to="/settings/team" @click="closeMenu">تیم و نقشها</RouterLink>
|
||||
<RouterLink to="/settings/automations" @click="closeMenu">اتوماسیون</RouterLink>
|
||||
<RouterLink to="/settings/lead-forms" @click="closeMenu">فرمساز سرنخ</RouterLink>
|
||||
<RouterLink to="/settings/custom-fields" @click="closeMenu">فیلدهای سفارشی</RouterLink>
|
||||
<RouterLink to="/settings/currencies" @click="closeMenu">ارزها</RouterLink>
|
||||
@@ -477,7 +486,6 @@ onUnmounted(() => {
|
||||
<RouterLink to="/loyalty" @click="closeMenu">باشگاه مشتریان</RouterLink>
|
||||
<template v-if="canSettings">
|
||||
<p>تنظیمات</p>
|
||||
<RouterLink to="/settings/automations" @click="closeMenu">اتوماسیون</RouterLink>
|
||||
<RouterLink to="/settings/lead-forms" @click="closeMenu">فرمساز سرنخ</RouterLink>
|
||||
<RouterLink to="/settings/custom-fields" @click="closeMenu">فیلدهای سفارشی</RouterLink>
|
||||
<RouterLink to="/settings/currencies" @click="closeMenu">ارزها</RouterLink>
|
||||
|
||||
@@ -61,14 +61,15 @@ function makeStep(action: AutomationAction): AutomationStep {
|
||||
}
|
||||
|
||||
function makeCondition(): AutomationStep {
|
||||
const field =
|
||||
trigger.value === 'deal_won' ? 'deal_amount' : 'ticket_priority';
|
||||
const field = conditionFieldsForTrigger(trigger.value)[0] ?? 'has_person';
|
||||
return {
|
||||
id: `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'condition',
|
||||
conditionField: field,
|
||||
conditionOp: field === 'deal_amount' ? 'gte' : 'eq',
|
||||
conditionValue: field === 'deal_amount' ? 0 : 'normal',
|
||||
conditionOp:
|
||||
field === 'deal_amount' ? 'gte' : field === 'ticket_priority' ? 'eq' : 'is_set',
|
||||
conditionValue:
|
||||
field === 'deal_amount' ? 0 : field === 'ticket_priority' ? 'normal' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -256,12 +257,23 @@ onMounted(async () => {
|
||||
|
||||
watch(trigger, (next) => {
|
||||
const allowed = conditionFieldsForTrigger(next);
|
||||
const fallback = allowed[0] ?? 'has_person';
|
||||
for (const step of steps.value) {
|
||||
if (!isAutomationCondition(step)) continue;
|
||||
if (step.conditionField && allowed.includes(step.conditionField)) continue;
|
||||
step.conditionField = next === 'deal_won' ? 'deal_amount' : 'ticket_priority';
|
||||
step.conditionOp = next === 'deal_won' ? 'gte' : 'eq';
|
||||
step.conditionValue = next === 'deal_won' ? 0 : 'normal';
|
||||
step.conditionField = fallback;
|
||||
step.conditionOp =
|
||||
fallback === 'deal_amount'
|
||||
? 'gte'
|
||||
: fallback === 'ticket_priority'
|
||||
? 'eq'
|
||||
: 'is_set';
|
||||
step.conditionValue =
|
||||
fallback === 'deal_amount'
|
||||
? 0
|
||||
: fallback === 'ticket_priority'
|
||||
? 'normal'
|
||||
: undefined;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -398,6 +410,7 @@ h1 {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.9rem;
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rules,
|
||||
@@ -524,11 +537,16 @@ h2 {
|
||||
|
||||
.stage {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stage :deep(.canvas) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -573,11 +591,38 @@ h2 {
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.workspace {
|
||||
grid-template-columns: minmax(15rem, 17.5rem) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(15rem, 20rem) minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
'rules toolbar'
|
||||
'stage stage';
|
||||
}
|
||||
|
||||
.rules {
|
||||
grid-area: rules;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.studio {
|
||||
display: contents;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
grid-area: toolbar;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
||||
padding: 0.85rem 1rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.stage {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(16rem, 19rem);
|
||||
grid-area: stage;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1722,15 +1722,35 @@ export interface RedeemLoyaltyRewardRequest {
|
||||
}
|
||||
|
||||
/** اتوماسیون اگر/آنگاه */
|
||||
export type AutomationTrigger = "deal_won" | "ticket_resolved";
|
||||
export type AutomationTrigger =
|
||||
| "person_created"
|
||||
| "lead_submitted"
|
||||
| "deal_created"
|
||||
| "deal_stage_changed"
|
||||
| "deal_won"
|
||||
| "deal_lost"
|
||||
| "ticket_created"
|
||||
| "ticket_resolved"
|
||||
| "invoice_issued"
|
||||
| "invoice_paid"
|
||||
| "activity_done";
|
||||
export type AutomationAction =
|
||||
| "create_activity"
|
||||
| "create_notification"
|
||||
| "add_loyalty_points";
|
||||
|
||||
export const AUTOMATION_TRIGGER_LABELS: Record<AutomationTrigger, string> = {
|
||||
person_created: "مخاطب جدید ساخته شد",
|
||||
lead_submitted: "فرم سرنخ ارسال شد",
|
||||
deal_created: "معامله جدید ساخته شد",
|
||||
deal_stage_changed: "مرحله معامله عوض شد",
|
||||
deal_won: "معامله برنده شد",
|
||||
deal_lost: "معامله باخت",
|
||||
ticket_created: "تیکت جدید ساخته شد",
|
||||
ticket_resolved: "تیکت حل شد",
|
||||
invoice_issued: "فاکتور صادر شد",
|
||||
invoice_paid: "پرداخت روی فاکتور ثبت شد",
|
||||
activity_done: "فعالیت تمام شد",
|
||||
};
|
||||
|
||||
export const AUTOMATION_ACTION_LABELS: Record<AutomationAction, string> = {
|
||||
@@ -1748,10 +1768,33 @@ export const AUTOMATION_ACTIONS = [
|
||||
] as const satisfies readonly AutomationAction[];
|
||||
|
||||
export const AUTOMATION_TRIGGERS = [
|
||||
"person_created",
|
||||
"lead_submitted",
|
||||
"deal_created",
|
||||
"deal_stage_changed",
|
||||
"deal_won",
|
||||
"deal_lost",
|
||||
"ticket_created",
|
||||
"ticket_resolved",
|
||||
"invoice_issued",
|
||||
"invoice_paid",
|
||||
"activity_done",
|
||||
] as const satisfies readonly AutomationTrigger[];
|
||||
|
||||
export const AUTOMATION_TRIGGER_GROUPS: {
|
||||
label: string;
|
||||
items: AutomationTrigger[];
|
||||
}[] = [
|
||||
{ label: "مخاطب", items: ["person_created", "lead_submitted"] },
|
||||
{
|
||||
label: "معامله",
|
||||
items: ["deal_created", "deal_stage_changed", "deal_won", "deal_lost"],
|
||||
},
|
||||
{ label: "تیکت", items: ["ticket_created", "ticket_resolved"] },
|
||||
{ label: "فعالیت", items: ["activity_done"] },
|
||||
{ label: "مالی", items: ["invoice_issued", "invoice_paid"] },
|
||||
];
|
||||
|
||||
export type AutomationStepKind = "action" | "condition";
|
||||
|
||||
export type AutomationConditionField =
|
||||
@@ -1822,10 +1865,18 @@ export function firstAutomationAction(
|
||||
export function conditionFieldsForTrigger(
|
||||
trigger: AutomationTrigger,
|
||||
): AutomationConditionField[] {
|
||||
if (trigger === "deal_won") {
|
||||
if (
|
||||
trigger === "deal_won" ||
|
||||
trigger === "deal_lost" ||
|
||||
trigger === "deal_created" ||
|
||||
trigger === "deal_stage_changed"
|
||||
) {
|
||||
return ["deal_amount", "has_person", "has_company"];
|
||||
}
|
||||
return ["ticket_priority", "has_person", "has_company"];
|
||||
if (trigger === "ticket_resolved" || trigger === "ticket_created") {
|
||||
return ["ticket_priority", "has_person", "has_company"];
|
||||
}
|
||||
return ["has_person", "has_company"];
|
||||
}
|
||||
|
||||
export interface AutomationStep {
|
||||
|
||||
Reference in New Issue
Block a user