feat(automations): form-driven branching rules, round-robin assignment, lead-linked activities
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
"prisma:seed": "tsx prisma/seed.ts",
|
"prisma:seed": "tsx prisma/seed.ts",
|
||||||
"lint": "echo \"api lint placeholder\"",
|
"lint": "echo \"api lint placeholder\"",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json",
|
||||||
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts"
|
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mento/shared": "*",
|
"@mento/shared": "*",
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Activity" ADD COLUMN "leadId" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AutomationCursor" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"ruleId" TEXT NOT NULL,
|
||||||
|
"stepId" TEXT NOT NULL,
|
||||||
|
"counter" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "AutomationCursor_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Activity_leadId_idx" ON "Activity"("leadId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "AutomationCursor_ruleId_stepId_key" ON "AutomationCursor"("ruleId", "stepId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AutomationCursor" ADD CONSTRAINT "AutomationCursor_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "AutomationRule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -725,11 +725,15 @@ model Activity {
|
|||||||
fields Json?
|
fields Json?
|
||||||
/// کاربران شرکتکننده؛ فعالیت در لیست آنها هم دیده میشود
|
/// کاربران شرکتکننده؛ فعالیت در لیست آنها هم دیده میشود
|
||||||
participantIds String[] @default([])
|
participantIds String[] @default([])
|
||||||
|
/// لید مرتبط (مثلاً فعالیت ساختهشده از ارسال فرم)
|
||||||
|
leadId String?
|
||||||
|
lead Lead? @relation(fields: [leadId], references: [id], onDelete: SetNull)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([stageId])
|
@@index([stageId])
|
||||||
@@index([typeDefId])
|
@@index([typeDefId])
|
||||||
|
@@index([leadId])
|
||||||
@@index([organizationId, status, dueAt])
|
@@index([organizationId, status, dueAt])
|
||||||
@@index([organizationId, assigneeId])
|
@@index([organizationId, assigneeId])
|
||||||
@@index([organizationId, createdById])
|
@@index([organizationId, createdById])
|
||||||
@@ -1443,12 +1447,25 @@ model AutomationRule {
|
|||||||
action AutomationAction
|
action AutomationAction
|
||||||
/// عنوان فعالیت/اعلان، assigneeId، notifyUserId، points، activityType
|
/// عنوان فعالیت/اعلان، assigneeId، notifyUserId، points، activityType
|
||||||
config Json @default("{}")
|
config Json @default("{}")
|
||||||
|
cursors AutomationCursor[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([organizationId, isActive, trigger])
|
@@index([organizationId, isActive, trigger])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// شمارندهٔ تقسیم نوبتی برای هر گام عمل در یک قانون اتوماسیون
|
||||||
|
model AutomationCursor {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
ruleId String
|
||||||
|
rule AutomationRule @relation(fields: [ruleId], references: [id], onDelete: Cascade)
|
||||||
|
stepId String
|
||||||
|
counter Int @default(0)
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([ruleId, stepId])
|
||||||
|
}
|
||||||
|
|
||||||
enum MessageAutomationTrigger {
|
enum MessageAutomationTrigger {
|
||||||
person_created
|
person_created
|
||||||
lead_submitted
|
lead_submitted
|
||||||
@@ -2400,6 +2417,7 @@ model Lead {
|
|||||||
trackingLinks TrackingLink[]
|
trackingLinks TrackingLink[]
|
||||||
trackingEvents TrackingEvent[]
|
trackingEvents TrackingEvent[]
|
||||||
routineTasks RoutineTask[]
|
routineTasks RoutineTask[]
|
||||||
|
activities Activity[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ export class ActivitiesController {
|
|||||||
personId: query.personId,
|
personId: query.personId,
|
||||||
companyId: query.companyId,
|
companyId: query.companyId,
|
||||||
dealId: query.dealId,
|
dealId: query.dealId,
|
||||||
|
leadId: query.leadId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: query.from,
|
from: query.from,
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ type ActivityRow = {
|
|||||||
person: { id: string; firstName: string; lastName: string } | null;
|
person: { id: string; firstName: string; lastName: string } | null;
|
||||||
company: { id: string; name: string } | null;
|
company: { id: string; name: string } | null;
|
||||||
deal: { id: string; title: string } | null;
|
deal: { id: string; title: string } | null;
|
||||||
|
lead?: { id: string; title: string } | null;
|
||||||
assignee: { id: string; fullName: string } | null;
|
assignee: { id: string; fullName: string } | null;
|
||||||
assignees: Array<{ id: string; fullName: string }>;
|
assignees: Array<{ id: string; fullName: string }>;
|
||||||
createdBy: { id: string; fullName: string } | null;
|
createdBy: { id: string; fullName: string } | null;
|
||||||
@@ -84,6 +85,7 @@ const include = {
|
|||||||
person: { select: { id: true, firstName: true, lastName: true } },
|
person: { select: { id: true, firstName: true, lastName: true } },
|
||||||
company: { select: { id: true, name: true } },
|
company: { select: { id: true, name: true } },
|
||||||
deal: { select: { id: true, title: true } },
|
deal: { select: { id: true, title: true } },
|
||||||
|
lead: { select: { id: true, title: true } },
|
||||||
assignee: { select: { id: true, fullName: true } },
|
assignee: { select: { id: true, fullName: true } },
|
||||||
assignees: { select: { id: true, fullName: true }, orderBy: { fullName: 'asc' as const } },
|
assignees: { select: { id: true, fullName: true }, orderBy: { fullName: 'asc' as const } },
|
||||||
createdBy: { select: { id: true, fullName: true } },
|
createdBy: { select: { id: true, fullName: true } },
|
||||||
@@ -190,6 +192,7 @@ export class ActivitiesService {
|
|||||||
personId?: string;
|
personId?: string;
|
||||||
companyId?: string;
|
companyId?: string;
|
||||||
dealId?: string;
|
dealId?: string;
|
||||||
|
leadId?: string;
|
||||||
},
|
},
|
||||||
range?: {
|
range?: {
|
||||||
from?: string;
|
from?: string;
|
||||||
@@ -602,6 +605,7 @@ export class ActivitiesService {
|
|||||||
personId?: string;
|
personId?: string;
|
||||||
companyId?: string;
|
companyId?: string;
|
||||||
dealId?: string;
|
dealId?: string;
|
||||||
|
leadId?: string;
|
||||||
},
|
},
|
||||||
range?: {
|
range?: {
|
||||||
from?: string;
|
from?: string;
|
||||||
@@ -643,6 +647,7 @@ export class ActivitiesService {
|
|||||||
...(entity?.personId ? [{ personId: entity.personId }] : []),
|
...(entity?.personId ? [{ personId: entity.personId }] : []),
|
||||||
...(entity?.companyId ? [{ companyId: entity.companyId }] : []),
|
...(entity?.companyId ? [{ companyId: entity.companyId }] : []),
|
||||||
...(entity?.dealId ? [{ dealId: entity.dealId }] : []),
|
...(entity?.dealId ? [{ dealId: entity.dealId }] : []),
|
||||||
|
...(entity?.leadId ? [{ leadId: entity.leadId }] : []),
|
||||||
];
|
];
|
||||||
const search = buildSearchWhere<Prisma.ActivityWhereInput>(q, (t) => [
|
const search = buildSearchWhere<Prisma.ActivityWhereInput>(q, (t) => [
|
||||||
{ title: ci(t) },
|
{ title: ci(t) },
|
||||||
@@ -872,6 +877,7 @@ export class ActivitiesService {
|
|||||||
: null,
|
: null,
|
||||||
company: row.company,
|
company: row.company,
|
||||||
deal: row.deal,
|
deal: row.deal,
|
||||||
|
lead: row.lead ?? null,
|
||||||
assignee: row.assignee,
|
assignee: row.assignee,
|
||||||
assignees: (() => {
|
assignees: (() => {
|
||||||
const list = [...(row.assignees ?? [])];
|
const list = [...(row.assignees ?? [])];
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ export class ListActivitiesQueryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
dealId?: string;
|
dealId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
leadId?: string;
|
||||||
|
|
||||||
/** Inclusive start (ISO). When set with/without filter, restricts dueAt. */
|
/** Inclusive start (ISO). When set with/without filter, restricts dueAt. */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export type FormFieldLabel = { key: string; label: string };
|
||||||
|
|
||||||
|
export type TemplateVars = {
|
||||||
|
title: string;
|
||||||
|
mobile?: string | null;
|
||||||
|
formName?: string | null;
|
||||||
|
formValues?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** counter یکواحد افزایشیافته از AutomationCursor است (از ۱ شروع میشود) */
|
||||||
|
export function pickRoundRobin(memberIds: string[], counter: number): string | null {
|
||||||
|
const ids = [...new Set(memberIds.filter(Boolean))].sort();
|
||||||
|
if (!ids.length) return null;
|
||||||
|
const n = Math.max(1, Math.trunc(counter));
|
||||||
|
return ids[(n - 1) % ids.length] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** جایگزینی {{title}}، {{mobile}}، {{form}} و {{field.<key>}} */
|
||||||
|
export function renderAutomationTemplate(text: string, vars: TemplateVars): string {
|
||||||
|
return text.replace(/\{\{\s*([\w.\u0600-\u06FF-]+)\s*\}\}/g, (_m, name: string) => {
|
||||||
|
if (name === 'title') return vars.title ?? '';
|
||||||
|
if (name === 'mobile') return vars.mobile ?? '';
|
||||||
|
if (name === 'form') return vars.formName ?? '';
|
||||||
|
if (name.startsWith('field.')) {
|
||||||
|
const v = vars.formValues?.[name.slice(6)];
|
||||||
|
return v == null ? '' : String(v);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFormAnswers(
|
||||||
|
fields: FormFieldLabel[] | null | undefined,
|
||||||
|
values: Record<string, unknown> | null | undefined,
|
||||||
|
): string {
|
||||||
|
if (!values) return '';
|
||||||
|
const lines: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const f of fields ?? []) {
|
||||||
|
seen.add(f.key);
|
||||||
|
const raw = values[f.key];
|
||||||
|
const v = raw == null ? '' : String(raw).trim();
|
||||||
|
if (v) lines.push(`${f.label}: ${v}`);
|
||||||
|
}
|
||||||
|
for (const [key, raw] of Object.entries(values)) {
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
const v = raw == null ? '' : String(raw).trim();
|
||||||
|
if (v) lines.push(`${key}: ${v}`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
@@ -20,30 +20,42 @@ import {
|
|||||||
MAX_AUTOMATION_STEPS,
|
MAX_AUTOMATION_STEPS,
|
||||||
automationStepToConfig,
|
automationStepToConfig,
|
||||||
firstAutomationAction,
|
firstAutomationAction,
|
||||||
isAutomationCondition,
|
matchFormFieldCondition,
|
||||||
normalizeAutomationSteps,
|
normalizeAutomationSteps,
|
||||||
packAutomationConfig,
|
packAutomationConfig,
|
||||||
|
planAutomationActions,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { LoyaltyService } from '../loyalty/loyalty.service';
|
import { LoyaltyService } from '../loyalty/loyalty.service';
|
||||||
import { SmsService } from '../common/sms.service';
|
import { SmsService } from '../common/sms.service';
|
||||||
|
import {
|
||||||
|
formatFormAnswers,
|
||||||
|
pickRoundRobin,
|
||||||
|
renderAutomationTemplate,
|
||||||
|
type FormFieldLabel,
|
||||||
|
} from './automation-engine.util';
|
||||||
|
|
||||||
export type AutomationContext = {
|
export type AutomationContext = {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
entityType: 'deal' | 'ticket' | 'person' | 'invoice' | 'activity';
|
entityType: 'deal' | 'ticket' | 'person' | 'invoice' | 'activity' | 'lead';
|
||||||
entityId: string;
|
entityId: string;
|
||||||
title: string;
|
title: string;
|
||||||
personId?: string | null;
|
personId?: string | null;
|
||||||
companyId?: string | null;
|
companyId?: string | null;
|
||||||
dealId?: string | null;
|
dealId?: string | null;
|
||||||
|
leadId?: string | null;
|
||||||
ownerId?: string | null;
|
ownerId?: string | null;
|
||||||
assigneeId?: string | null;
|
assigneeId?: string | null;
|
||||||
/** مثلاً id کمپین SMS برای فیلتر قانون */
|
/** مثلاً id کمپین SMS یا id فرم سرنخ برای فیلتر قانون */
|
||||||
sourceId?: string | null;
|
sourceId?: string | null;
|
||||||
sourceType?: string | null;
|
sourceType?: string | null;
|
||||||
clickCount?: number | null;
|
clickCount?: number | null;
|
||||||
|
mobile?: string | null;
|
||||||
|
formName?: string | null;
|
||||||
|
formValues?: Record<string, unknown> | null;
|
||||||
|
formFields?: FormFieldLabel[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AutomationFacts = {
|
type AutomationFacts = {
|
||||||
@@ -51,8 +63,11 @@ type AutomationFacts = {
|
|||||||
hasPerson: boolean;
|
hasPerson: boolean;
|
||||||
hasCompany: boolean;
|
hasCompany: boolean;
|
||||||
ticketPriority: string | null;
|
ticketPriority: string | null;
|
||||||
|
formValues: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type StepRef = { ruleId: string; stepId: string };
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AutomationsService {
|
export class AutomationsService {
|
||||||
private readonly logger = new Logger(AutomationsService.name);
|
private readonly logger = new Logger(AutomationsService.name);
|
||||||
@@ -79,6 +94,7 @@ export class AutomationsService {
|
|||||||
const steps = resolveSteps(dto.action, dto.config);
|
const steps = resolveSteps(dto.action, dto.config);
|
||||||
const action = firstAutomationAction(steps);
|
const action = firstAutomationAction(steps);
|
||||||
if (!action) throw new BadRequestException('حداقل یک عمل لازم است');
|
if (!action) throw new BadRequestException('حداقل یک عمل لازم است');
|
||||||
|
await this.assertStepRefs(organizationId, steps);
|
||||||
const row = await this.prisma.automationRule.create({
|
const row = await this.prisma.automationRule.create({
|
||||||
data: {
|
data: {
|
||||||
organizationId,
|
organizationId,
|
||||||
@@ -86,7 +102,7 @@ export class AutomationsService {
|
|||||||
trigger: dto.trigger,
|
trigger: dto.trigger,
|
||||||
action,
|
action,
|
||||||
isActive: dto.isActive ?? true,
|
isActive: dto.isActive ?? true,
|
||||||
config: packAutomationConfig(steps) as Prisma.InputJsonValue,
|
config: packAutomationConfig(steps, chainExtra(dto.config)) as Prisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return this.toDto(row);
|
return this.toDto(row);
|
||||||
@@ -114,6 +130,7 @@ export class AutomationsService {
|
|||||||
if (steps && !nextAction) {
|
if (steps && !nextAction) {
|
||||||
throw new BadRequestException('حداقل یک عمل لازم است');
|
throw new BadRequestException('حداقل یک عمل لازم است');
|
||||||
}
|
}
|
||||||
|
if (steps) await this.assertStepRefs(organizationId, steps);
|
||||||
|
|
||||||
const row = await this.prisma.automationRule.update({
|
const row = await this.prisma.automationRule.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -124,7 +141,10 @@ export class AutomationsService {
|
|||||||
...(steps && nextAction
|
...(steps && nextAction
|
||||||
? {
|
? {
|
||||||
action: nextAction,
|
action: nextAction,
|
||||||
config: packAutomationConfig(steps) as Prisma.InputJsonValue,
|
config: packAutomationConfig(
|
||||||
|
steps,
|
||||||
|
chainExtra(dto.config ?? asConfig(existing.config)),
|
||||||
|
) as Prisma.InputJsonValue,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
@@ -151,7 +171,11 @@ export class AutomationsService {
|
|||||||
trigger,
|
trigger,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const facts = await this.loadFacts(ctx);
|
if (!rules.length) return;
|
||||||
|
const facts: AutomationFacts = {
|
||||||
|
...(await this.loadFacts(ctx)),
|
||||||
|
formValues: ctx.formValues ?? {},
|
||||||
|
};
|
||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
try {
|
try {
|
||||||
const config = asConfig(rule.config);
|
const config = asConfig(rule.config);
|
||||||
@@ -167,13 +191,20 @@ export class AutomationsService {
|
|||||||
rule.action as AutomationAction,
|
rule.action as AutomationAction,
|
||||||
config,
|
config,
|
||||||
);
|
);
|
||||||
for (const step of steps) {
|
const actions = planAutomationActions(steps, config.chainMode, (step) =>
|
||||||
if (isAutomationCondition(step)) {
|
matchCondition(step, facts),
|
||||||
if (!matchCondition(step, facts)) break;
|
);
|
||||||
continue;
|
for (const step of actions) {
|
||||||
|
try {
|
||||||
|
await this.execute(step.action!, automationStepToConfig(step), ctx, {
|
||||||
|
ruleId: rule.id,
|
||||||
|
stepId: step.id,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Automation ${rule.id} step ${step.id} failed: ${(e as Error)?.message ?? e}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!step.action) continue;
|
|
||||||
await this.execute(step.action, automationStepToConfig(step), ctx);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// قوانین نباید شکست عملیات اصلی را ایجاد کنند
|
// قوانین نباید شکست عملیات اصلی را ایجاد کنند
|
||||||
@@ -181,7 +212,9 @@ export class AutomationsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadFacts(ctx: AutomationContext): Promise<AutomationFacts> {
|
private async loadFacts(
|
||||||
|
ctx: AutomationContext,
|
||||||
|
): Promise<Omit<AutomationFacts, 'formValues'>> {
|
||||||
if (ctx.entityType === 'deal') {
|
if (ctx.entityType === 'deal') {
|
||||||
const deal = await this.prisma.deal.findFirst({
|
const deal = await this.prisma.deal.findFirst({
|
||||||
where: { id: ctx.entityId, organizationId: ctx.organizationId },
|
where: { id: ctx.entityId, organizationId: ctx.organizationId },
|
||||||
@@ -258,42 +291,26 @@ export class AutomationsService {
|
|||||||
|
|
||||||
private async execute(
|
private async execute(
|
||||||
action: AutomationAction,
|
action: AutomationAction,
|
||||||
config: AutomationRuleConfig,
|
rawConfig: AutomationRuleConfig,
|
||||||
ctx: AutomationContext,
|
ctx: AutomationContext,
|
||||||
|
ref: StepRef,
|
||||||
) {
|
) {
|
||||||
|
const vars = {
|
||||||
|
title: ctx.title,
|
||||||
|
mobile: ctx.mobile,
|
||||||
|
formName: ctx.formName,
|
||||||
|
formValues: ctx.formValues,
|
||||||
|
};
|
||||||
|
const config: AutomationRuleConfig = {
|
||||||
|
...rawConfig,
|
||||||
|
title: rawConfig.title ? renderAutomationTemplate(rawConfig.title, vars) : rawConfig.title,
|
||||||
|
body: rawConfig.body ? renderAutomationTemplate(rawConfig.body, vars) : rawConfig.body,
|
||||||
|
};
|
||||||
const title =
|
const title =
|
||||||
(config.title?.trim() || defaultTitle(action, ctx.title)).slice(0, 200);
|
(config.title?.trim() || defaultTitle(action, ctx.title)).slice(0, 200);
|
||||||
|
|
||||||
if (action === 'create_activity') {
|
if (action === 'create_activity') {
|
||||||
const assigneeId =
|
await this.executeCreateActivity(title, config, ctx, ref);
|
||||||
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: entityLink(ctx),
|
|
||||||
entityType: ctx.entityType,
|
|
||||||
entityId: ctx.entityId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,6 +330,163 @@ export class AutomationsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async executeCreateActivity(
|
||||||
|
title: string,
|
||||||
|
config: AutomationRuleConfig,
|
||||||
|
ctx: AutomationContext,
|
||||||
|
ref: StepRef,
|
||||||
|
) {
|
||||||
|
const assigneeId = await this.resolveAssignee(config, ctx, ref);
|
||||||
|
|
||||||
|
let type = (config.activityType as 'call' | 'meeting' | 'task' | 'note') || 'task';
|
||||||
|
let typeDefId: string | null = null;
|
||||||
|
if (config.activityTypeDefId) {
|
||||||
|
const def = await this.prisma.activityTypeDef.findFirst({
|
||||||
|
where: { id: config.activityTypeDefId, organizationId: ctx.organizationId },
|
||||||
|
select: { id: true, baseType: true },
|
||||||
|
});
|
||||||
|
if (def) {
|
||||||
|
typeDefId = def.id;
|
||||||
|
type = def.baseType as typeof type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = config.dueOffsetMinutes;
|
||||||
|
const dueAt =
|
||||||
|
typeof offset === 'number' && Number.isFinite(offset)
|
||||||
|
? new Date(Date.now() + Math.max(0, offset) * 60_000)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const description = [
|
||||||
|
config.body?.trim(),
|
||||||
|
leadSummary(ctx),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n')
|
||||||
|
.slice(0, 5000);
|
||||||
|
|
||||||
|
const activity = await this.prisma.activity.create({
|
||||||
|
data: {
|
||||||
|
organizationId: ctx.organizationId,
|
||||||
|
type,
|
||||||
|
typeDefId,
|
||||||
|
title,
|
||||||
|
description: description || null,
|
||||||
|
dueAt,
|
||||||
|
personId: ctx.personId ?? null,
|
||||||
|
companyId: ctx.companyId ?? null,
|
||||||
|
dealId: ctx.dealId ?? (ctx.entityType === 'deal' ? ctx.entityId : null),
|
||||||
|
leadId: ctx.leadId ?? (ctx.entityType === 'lead' ? ctx.entityId : null),
|
||||||
|
assigneeId,
|
||||||
|
assignees: assigneeId ? { connect: [{ id: assigneeId }] } : undefined,
|
||||||
|
tags: ['automation'],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (assigneeId) {
|
||||||
|
await this.notifications.create({
|
||||||
|
organizationId: ctx.organizationId,
|
||||||
|
userId: assigneeId,
|
||||||
|
type: 'automation',
|
||||||
|
title: 'فعالیت خودکار',
|
||||||
|
body: title,
|
||||||
|
link: `/activities/${activity.id}`,
|
||||||
|
entityType: 'activity',
|
||||||
|
entityId: activity.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveAssignee(
|
||||||
|
config: AutomationRuleConfig,
|
||||||
|
ctx: AutomationContext,
|
||||||
|
ref: StepRef,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const fallback = ctx.assigneeId || ctx.ownerId || null;
|
||||||
|
const mode = config.assignMode ?? (config.assigneeId ? 'user' : 'owner');
|
||||||
|
|
||||||
|
if (mode === 'user') {
|
||||||
|
const id = config.assigneeId?.trim();
|
||||||
|
if (id && (await this.isMember(ctx.organizationId, id))) return id;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'team_round_robin' || mode === 'users_round_robin') {
|
||||||
|
let candidates: string[] = [];
|
||||||
|
if (mode === 'team_round_robin' && config.assigneeTeamId) {
|
||||||
|
const members = await this.prisma.teamMember.findMany({
|
||||||
|
where: {
|
||||||
|
teamId: config.assigneeTeamId,
|
||||||
|
team: { organizationId: ctx.organizationId },
|
||||||
|
},
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
candidates = members.map((m) => m.userId);
|
||||||
|
} else if (mode === 'users_round_robin') {
|
||||||
|
candidates = config.assigneeUserIds ?? [];
|
||||||
|
}
|
||||||
|
const valid = await this.filterMembers(ctx.organizationId, candidates);
|
||||||
|
if (!valid.length) return fallback;
|
||||||
|
const cursor = await this.prisma.automationCursor.upsert({
|
||||||
|
where: { ruleId_stepId: { ruleId: ref.ruleId, stepId: ref.stepId } },
|
||||||
|
create: { ruleId: ref.ruleId, stepId: ref.stepId, counter: 1 },
|
||||||
|
update: { counter: { increment: 1 } },
|
||||||
|
select: { counter: true },
|
||||||
|
});
|
||||||
|
return pickRoundRobin(valid, cursor.counter) ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isMember(organizationId: string, userId: string) {
|
||||||
|
return (await this.filterMembers(organizationId, [userId])).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async filterMembers(organizationId: string, userIds: string[]) {
|
||||||
|
const ids = [...new Set(userIds.filter(Boolean))];
|
||||||
|
if (!ids.length) return [];
|
||||||
|
const rows = await this.prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: ids },
|
||||||
|
memberships: { some: { organizationId } },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return rows.map((r) => r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertStepRefs(organizationId: string, steps: AutomationStep[]) {
|
||||||
|
for (const step of steps) {
|
||||||
|
if (step.kind === 'condition' || step.action !== 'create_activity') continue;
|
||||||
|
if (step.assignMode === 'team_round_robin') {
|
||||||
|
if (!step.assigneeTeamId) {
|
||||||
|
throw new BadRequestException('برای تقسیم نوبتی، تیم را انتخاب کنید');
|
||||||
|
}
|
||||||
|
const team = await this.prisma.team.findFirst({
|
||||||
|
where: { id: step.assigneeTeamId, organizationId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!team) throw new BadRequestException('تیم انتخابشده معتبر نیست');
|
||||||
|
}
|
||||||
|
if (step.assignMode === 'users_round_robin') {
|
||||||
|
const ids = step.assigneeUserIds ?? [];
|
||||||
|
if (!ids.length) {
|
||||||
|
throw new BadRequestException('برای تقسیم نوبتی، حداقل یک کاربر انتخاب کنید');
|
||||||
|
}
|
||||||
|
const valid = await this.filterMembers(organizationId, ids);
|
||||||
|
if (valid.length !== new Set(ids).size) {
|
||||||
|
throw new BadRequestException('یکی از کاربران انتخابشده معتبر نیست');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (step.assignMode === 'user' && step.assigneeId) {
|
||||||
|
if (!(await this.isMember(organizationId, step.assigneeId))) {
|
||||||
|
throw new BadRequestException('مسئول انتخابشده معتبر نیست');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async executeNotify(
|
private async executeNotify(
|
||||||
title: string,
|
title: string,
|
||||||
config: AutomationRuleConfig,
|
config: AutomationRuleConfig,
|
||||||
@@ -464,9 +638,37 @@ function matchCondition(step: AutomationStep, facts: AutomationFacts): boolean {
|
|||||||
return current === value;
|
return current === value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (field === 'form_field') {
|
||||||
|
const key = step.conditionKey?.trim();
|
||||||
|
if (!key) return false;
|
||||||
|
return matchFormFieldCondition(op, facts.formValues[key], step.conditionValue);
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chainExtra(
|
||||||
|
config?: AutomationRuleConfig | null,
|
||||||
|
): Pick<AutomationRuleConfig, 'sourceId' | 'chainMode'> {
|
||||||
|
return {
|
||||||
|
sourceId: config?.sourceId ?? null,
|
||||||
|
chainMode: config?.chainMode === 'branches' ? 'branches' : config?.chainMode === 'stop' ? 'stop' : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function leadSummary(ctx: AutomationContext): string {
|
||||||
|
if (ctx.sourceType !== 'lead_form') return '';
|
||||||
|
const head = [
|
||||||
|
ctx.formName ? `فرم: ${ctx.formName}` : '',
|
||||||
|
ctx.title ? `نام: ${ctx.title}` : '',
|
||||||
|
ctx.mobile ? `موبایل: ${ctx.mobile}` : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
const answers = formatFormAnswers(ctx.formFields, ctx.formValues);
|
||||||
|
return [head.join('\n'), answers ? `پاسخهای فرم:\n${answers}` : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
function defaultTitle(action: AutomationAction, entityTitle: string) {
|
function defaultTitle(action: AutomationAction, entityTitle: string) {
|
||||||
if (action === 'create_activity') return `پیگیری خودکار: ${entityTitle}`;
|
if (action === 'create_activity') return `پیگیری خودکار: ${entityTitle}`;
|
||||||
if (action === 'create_notification') return `اتوماسیون: ${entityTitle}`;
|
if (action === 'create_notification') return `اتوماسیون: ${entityTitle}`;
|
||||||
@@ -479,5 +681,6 @@ function entityLink(ctx: AutomationContext) {
|
|||||||
if (ctx.entityType === 'invoice') return `/invoices/${ctx.entityId}`;
|
if (ctx.entityType === 'invoice') return `/invoices/${ctx.entityId}`;
|
||||||
if (ctx.entityType === 'person') return `/contacts/people/${ctx.entityId}`;
|
if (ctx.entityType === 'person') return `/contacts/people/${ctx.entityId}`;
|
||||||
if (ctx.entityType === 'activity') return `/activities/${ctx.entityId}`;
|
if (ctx.entityType === 'activity') return `/activities/${ctx.entityId}`;
|
||||||
|
if (ctx.entityType === 'lead') return `/leads/${ctx.entityId}`;
|
||||||
return '/';
|
return '/';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -385,7 +385,19 @@ export class LeadFormsService {
|
|||||||
personId,
|
personId,
|
||||||
ownerId: ownerId ?? actorId,
|
ownerId: ownerId ?? actorId,
|
||||||
};
|
};
|
||||||
await this.automations.runTrigger('lead_submitted', leadCtx);
|
await this.automations.runTrigger('lead_submitted', {
|
||||||
|
...leadCtx,
|
||||||
|
entityType: personId ? 'person' : leadId ? 'lead' : 'person',
|
||||||
|
sourceId: form.id,
|
||||||
|
sourceType: 'lead_form',
|
||||||
|
leadId,
|
||||||
|
mobile: mapped.mobile ?? mapped.phone ?? null,
|
||||||
|
formName: form.name,
|
||||||
|
formValues: values,
|
||||||
|
formFields: form.fields
|
||||||
|
.filter((f) => f.fieldType !== 'heading')
|
||||||
|
.map((f) => ({ key: f.key, label: f.label })),
|
||||||
|
});
|
||||||
await this.messageAutomations.onEvent('lead_submitted', leadCtx);
|
await this.messageAutomations.onEvent('lead_submitted', leadCtx);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
matchFormFieldCondition,
|
||||||
|
planAutomationActions,
|
||||||
|
splitFormAnswer,
|
||||||
|
type AutomationStep,
|
||||||
|
} from '@mento/shared';
|
||||||
|
import {
|
||||||
|
formatFormAnswers,
|
||||||
|
pickRoundRobin,
|
||||||
|
renderAutomationTemplate,
|
||||||
|
} from '../src/automations/automation-engine.util';
|
||||||
|
|
||||||
|
const KEY = 'coop_type';
|
||||||
|
const answers = (raw: string): Record<string, string> => ({ [KEY]: raw });
|
||||||
|
|
||||||
|
function cond(id: string, value: string): AutomationStep {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
kind: 'condition',
|
||||||
|
conditionField: 'form_field',
|
||||||
|
conditionKey: KEY,
|
||||||
|
conditionOp: 'contains',
|
||||||
|
conditionValue: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function act(id: string): AutomationStep {
|
||||||
|
return { id, kind: 'action', action: 'create_activity' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const BSJ: AutomationStep[] = [
|
||||||
|
cond('c1', 'بنکداری'),
|
||||||
|
act('a1'),
|
||||||
|
cond('c2', 'آنلاین شاپ'),
|
||||||
|
act('a2'),
|
||||||
|
cond('c3', 'ویترین نقره'),
|
||||||
|
act('a3'),
|
||||||
|
];
|
||||||
|
|
||||||
|
function run(steps: AutomationStep[], raw: string, mode: 'branches' | 'stop' = 'branches') {
|
||||||
|
const values = answers(raw);
|
||||||
|
return planAutomationActions(steps, mode, (s) =>
|
||||||
|
matchFormFieldCondition(s.conditionOp, values[s.conditionKey ?? ''], s.conditionValue),
|
||||||
|
).map((s) => s.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('اتوماسیون شاخهای فرم', () => {
|
||||||
|
it('چندانتخابی را میشکند', () => {
|
||||||
|
assert.deepEqual(splitFormAnswer('بنکداری, آنلاین شاپ'), ['بنکداری', 'آنلاین شاپ']);
|
||||||
|
assert.deepEqual(splitFormAnswer('الف، ب'), ['الف', 'ب']);
|
||||||
|
assert.deepEqual(splitFormAnswer(''), []);
|
||||||
|
assert.deepEqual(splitFormAnswer(undefined), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('عملگرهای فیلد فرم', () => {
|
||||||
|
assert.equal(matchFormFieldCondition('contains', 'بنکداری, ویترین نقره', 'ویترین نقره'), true);
|
||||||
|
assert.equal(matchFormFieldCondition('contains', 'بنکداری', 'آنلاین شاپ'), false);
|
||||||
|
assert.equal(matchFormFieldCondition('not_contains', 'بنکداری', 'آنلاین شاپ'), true);
|
||||||
|
assert.equal(matchFormFieldCondition('eq', 'بنکداری', 'بنکداری'), true);
|
||||||
|
assert.equal(matchFormFieldCondition('eq', 'بنکداری, آنلاین شاپ', 'بنکداری'), false);
|
||||||
|
assert.equal(matchFormFieldCondition('is_set', 'x', ''), true);
|
||||||
|
assert.equal(matchFormFieldCondition('is_empty', '', ''), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('هر گزینه فقط شاخهٔ خودش را اجرا میکند', () => {
|
||||||
|
assert.deepEqual(run(BSJ, 'بنکداری'), ['a1']);
|
||||||
|
assert.deepEqual(run(BSJ, 'آنلاین شاپ'), ['a2']);
|
||||||
|
assert.deepEqual(run(BSJ, 'ویترین نقره'), ['a3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('چند تیک = چند فعالیت', () => {
|
||||||
|
assert.deepEqual(run(BSJ, 'بنکداری, ویترین نقره'), ['a1', 'a3']);
|
||||||
|
assert.deepEqual(run(BSJ, 'بنکداری, آنلاین شاپ, ویترین نقره'), ['a1', 'a2', 'a3']);
|
||||||
|
assert.deepEqual(run(BSJ, ''), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('شرطهای پشتسرهم با «و» ترکیب میشوند', () => {
|
||||||
|
const steps = [cond('c1', 'بنکداری'), cond('c2', 'آنلاین شاپ'), act('a1'), act('a2')];
|
||||||
|
assert.deepEqual(run(steps, 'بنکداری'), []);
|
||||||
|
assert.deepEqual(run(steps, 'بنکداری, آنلاین شاپ'), ['a1', 'a2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('حالت stop رفتار قدیمی را حفظ میکند', () => {
|
||||||
|
assert.deepEqual(run(BSJ, 'آنلاین شاپ', 'stop'), []);
|
||||||
|
assert.deepEqual(run(BSJ, 'بنکداری, آنلاین شاپ, ویترین نقره', 'stop'), ['a1', 'a2', 'a3']);
|
||||||
|
assert.deepEqual(
|
||||||
|
planAutomationActions([act('a0')], undefined, () => false).map((s) => s.id),
|
||||||
|
['a0'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('تقسیم نوبتی مساوی و پایدار', () => {
|
||||||
|
const team = ['u3', 'u1', 'u2'];
|
||||||
|
const picks = [1, 2, 3, 4, 5, 6].map((n) => pickRoundRobin(team, n));
|
||||||
|
assert.deepEqual(picks, ['u1', 'u2', 'u3', 'u1', 'u2', 'u3']);
|
||||||
|
assert.equal(pickRoundRobin([], 1), null);
|
||||||
|
assert.equal(pickRoundRobin(['u1', 'u1'], 2), 'u1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('جایگذاری متغیرها و خلاصهٔ پاسخها', () => {
|
||||||
|
const text = renderAutomationTemplate('تماس با {{title}} ({{mobile}}) — {{field.coop_type}} از {{form}}', {
|
||||||
|
title: 'علی',
|
||||||
|
mobile: '0912',
|
||||||
|
formName: 'BSJ',
|
||||||
|
formValues: answers('بنکداری'),
|
||||||
|
});
|
||||||
|
assert.equal(text, 'تماس با علی (0912) — بنکداری از BSJ');
|
||||||
|
assert.equal(
|
||||||
|
formatFormAnswers([{ key: KEY, label: 'نوع همکاری' }], { [KEY]: 'بنکداری', extra: 'x' }),
|
||||||
|
'نوع همکاری: بنکداری\nextra: x',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,8 +11,12 @@ import {
|
|||||||
MAX_AUTOMATION_STEPS,
|
MAX_AUTOMATION_STEPS,
|
||||||
isAutomationCondition,
|
isAutomationCondition,
|
||||||
type AutomationAction,
|
type AutomationAction,
|
||||||
|
type AutomationChainMode,
|
||||||
type AutomationStep,
|
type AutomationStep,
|
||||||
type AutomationTrigger,
|
type AutomationTrigger,
|
||||||
|
type LeadFormDto,
|
||||||
|
type OrgUserDto,
|
||||||
|
type TeamDto,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { toFaDigits } from '@/lib/jalali';
|
import { toFaDigits } from '@/lib/jalali';
|
||||||
|
|
||||||
@@ -20,11 +24,44 @@ export type AutomationAddPayload =
|
|||||||
| { kind: 'action'; action: AutomationAction }
|
| { kind: 'action'; action: AutomationAction }
|
||||||
| { kind: 'condition' };
|
| { kind: 'condition' };
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
trigger: AutomationTrigger;
|
defineProps<{
|
||||||
steps: AutomationStep[];
|
trigger: AutomationTrigger;
|
||||||
selected: 'trigger' | string;
|
steps: AutomationStep[];
|
||||||
}>();
|
selected: 'trigger' | string;
|
||||||
|
chainMode?: AutomationChainMode;
|
||||||
|
form?: LeadFormDto | null;
|
||||||
|
users?: OrgUserDto[];
|
||||||
|
teams?: TeamDto[];
|
||||||
|
}>(),
|
||||||
|
{ chainMode: 'stop', form: null, users: () => [], teams: () => [] },
|
||||||
|
);
|
||||||
|
|
||||||
|
/** شرطی که یک شاخهٔ جدید را شروع میکند (گام قبلی شرط نیست) */
|
||||||
|
function isBranchStart(index: number) {
|
||||||
|
if (props.chainMode !== 'branches') return false;
|
||||||
|
const step = props.steps[index];
|
||||||
|
if (!step || !isAutomationCondition(step)) return false;
|
||||||
|
const prev = props.steps[index - 1];
|
||||||
|
return !prev || !isAutomationCondition(prev);
|
||||||
|
}
|
||||||
|
|
||||||
|
function userName(id?: string | null) {
|
||||||
|
return props.users.find((u) => u.id === id)?.fullName ?? 'کاربر';
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignHint(step: AutomationStep) {
|
||||||
|
const mode = step.assignMode ?? (step.assigneeId ? 'user' : 'owner');
|
||||||
|
if (mode === 'user') return userName(step.assigneeId);
|
||||||
|
if (mode === 'team_round_robin') {
|
||||||
|
const t = props.teams.find((x) => x.id === step.assigneeTeamId);
|
||||||
|
return `نوبتی: ${t?.name ?? 'تیم'}`;
|
||||||
|
}
|
||||||
|
if (mode === 'users_round_robin') {
|
||||||
|
return `نوبتی بین ${toFaDigits((step.assigneeUserIds ?? []).length)} نفر`;
|
||||||
|
}
|
||||||
|
return 'مسئول پرونده';
|
||||||
|
}
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [id: 'trigger' | string];
|
select: [id: 'trigger' | string];
|
||||||
@@ -55,6 +92,10 @@ function canRemove(step: AutomationStep) {
|
|||||||
function stepTitle(step: AutomationStep) {
|
function stepTitle(step: AutomationStep) {
|
||||||
if (isAutomationCondition(step)) {
|
if (isAutomationCondition(step)) {
|
||||||
const field = step.conditionField ?? 'has_person';
|
const field = step.conditionField ?? 'has_person';
|
||||||
|
if (field === 'form_field') {
|
||||||
|
const f = props.form?.fields.find((x) => x.key === step.conditionKey);
|
||||||
|
return f?.label ?? AUTOMATION_CONDITION_FIELD_LABELS[field];
|
||||||
|
}
|
||||||
return AUTOMATION_CONDITION_FIELD_LABELS[field];
|
return AUTOMATION_CONDITION_FIELD_LABELS[field];
|
||||||
}
|
}
|
||||||
return AUTOMATION_ACTION_LABELS[step.action ?? 'create_activity'];
|
return AUTOMATION_ACTION_LABELS[step.action ?? 'create_activity'];
|
||||||
@@ -64,6 +105,10 @@ function stepHint(step: AutomationStep) {
|
|||||||
if (isAutomationCondition(step)) {
|
if (isAutomationCondition(step)) {
|
||||||
const op = step.conditionOp ?? 'is_set';
|
const op = step.conditionOp ?? 'is_set';
|
||||||
const opLabel = AUTOMATION_CONDITION_OP_LABELS[op];
|
const opLabel = AUTOMATION_CONDITION_OP_LABELS[op];
|
||||||
|
if (step.conditionField === 'form_field') {
|
||||||
|
if (op === 'is_set' || op === 'is_empty') return opLabel;
|
||||||
|
return `${opLabel} «${step.conditionValue ?? ''}»`;
|
||||||
|
}
|
||||||
if (step.conditionField === 'deal_amount' && step.conditionValue != null) {
|
if (step.conditionField === 'deal_amount' && step.conditionValue != null) {
|
||||||
return `${opLabel} ${toFaDigits(Number(step.conditionValue))}`;
|
return `${opLabel} ${toFaDigits(Number(step.conditionValue))}`;
|
||||||
}
|
}
|
||||||
@@ -90,7 +135,7 @@ function stepHint(step: AutomationStep) {
|
|||||||
task: 'وظیفه',
|
task: 'وظیفه',
|
||||||
note: 'یادداشت',
|
note: 'یادداشت',
|
||||||
};
|
};
|
||||||
return types[step.activityType ?? 'task'] ?? 'وظیفه';
|
return `${types[step.activityType ?? 'task'] ?? 'وظیفه'} — ${assignHint(step)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAdd(index: number) {
|
function openAdd(index: number) {
|
||||||
@@ -148,10 +193,11 @@ function nodeClass(step: AutomationStep) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="node-wrap">
|
<div class="node-wrap">
|
||||||
|
<span v-if="isBranchStart(i)" class="branch-tag">شاخه</span>
|
||||||
<button
|
<button
|
||||||
class="node action"
|
class="node action"
|
||||||
type="button"
|
type="button"
|
||||||
:class="[nodeClass(step), { on: selected === step.id }]"
|
:class="[nodeClass(step), { on: selected === step.id, 'branch-start': isBranchStart(i) }]"
|
||||||
@click="emit('select', step.id)"
|
@click="emit('select', step.id)"
|
||||||
>
|
>
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
@@ -301,6 +347,25 @@ function nodeClass(step: AutomationStep) {
|
|||||||
filter: drop-shadow(0 8px 14px rgba(180, 83, 9, 0.14));
|
filter: drop-shadow(0 8px 14px rgba(180, 83, 9, 0.14));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.branch-tag {
|
||||||
|
position: absolute;
|
||||||
|
top: -1.35rem;
|
||||||
|
inset-inline-start: 50%;
|
||||||
|
transform: translateX(50%);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #b45309;
|
||||||
|
background: #fff7ed;
|
||||||
|
border: 1px solid #fed7aa;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.05rem 0.5rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node.condition.branch-start {
|
||||||
|
background: #fef3c7;
|
||||||
|
}
|
||||||
|
|
||||||
.node.on {
|
.node.on {
|
||||||
border-color: #14b8a6;
|
border-color: #14b8a6;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
|
|||||||
@@ -2,17 +2,24 @@
|
|||||||
import { computed, watch } from 'vue';
|
import { computed, watch } from 'vue';
|
||||||
import type {
|
import type {
|
||||||
ActivityType,
|
ActivityType,
|
||||||
|
ActivityTypeDefDto,
|
||||||
AutomationAction,
|
AutomationAction,
|
||||||
|
AutomationAssignMode,
|
||||||
|
AutomationChainMode,
|
||||||
AutomationConditionField,
|
AutomationConditionField,
|
||||||
AutomationConditionOp,
|
AutomationConditionOp,
|
||||||
AutomationStep,
|
AutomationStep,
|
||||||
AutomationTrigger,
|
AutomationTrigger,
|
||||||
|
LeadFormDto,
|
||||||
OrgUserDto,
|
OrgUserDto,
|
||||||
|
TeamDto,
|
||||||
TicketPriority,
|
TicketPriority,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import {
|
import {
|
||||||
AUTOMATION_ACTION_LABELS,
|
AUTOMATION_ACTION_LABELS,
|
||||||
AUTOMATION_ACTIONS,
|
AUTOMATION_ACTIONS,
|
||||||
|
AUTOMATION_ASSIGN_MODE_LABELS,
|
||||||
|
AUTOMATION_CHAIN_MODE_LABELS,
|
||||||
AUTOMATION_CONDITION_FIELD_LABELS,
|
AUTOMATION_CONDITION_FIELD_LABELS,
|
||||||
AUTOMATION_CONDITION_OP_LABELS,
|
AUTOMATION_CONDITION_OP_LABELS,
|
||||||
AUTOMATION_NOTIFY_CHANNEL_LABELS,
|
AUTOMATION_NOTIFY_CHANNEL_LABELS,
|
||||||
@@ -20,16 +27,22 @@ import {
|
|||||||
AUTOMATION_TRIGGER_GROUPS,
|
AUTOMATION_TRIGGER_GROUPS,
|
||||||
AUTOMATION_TRIGGER_LABELS,
|
AUTOMATION_TRIGGER_LABELS,
|
||||||
conditionFieldsForTrigger,
|
conditionFieldsForTrigger,
|
||||||
|
conditionOpsForField,
|
||||||
isAutomationCondition,
|
isAutomationCondition,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
selected: 'trigger' | string;
|
selected: 'trigger' | string;
|
||||||
users: OrgUserDto[];
|
users: OrgUserDto[];
|
||||||
|
forms: LeadFormDto[];
|
||||||
|
teams: TeamDto[];
|
||||||
|
activityTypeDefs: ActivityTypeDefDto[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const trigger = defineModel<AutomationTrigger>('trigger', { required: true });
|
const trigger = defineModel<AutomationTrigger>('trigger', { required: true });
|
||||||
const steps = defineModel<AutomationStep[]>('steps', { required: true });
|
const steps = defineModel<AutomationStep[]>('steps', { required: true });
|
||||||
|
const sourceId = defineModel<string | null>('sourceId', { default: null });
|
||||||
|
const chainMode = defineModel<AutomationChainMode>('chainMode', { default: 'branches' });
|
||||||
|
|
||||||
const selectedStep = computed(() => {
|
const selectedStep = computed(() => {
|
||||||
if (props.selected === 'trigger') return null;
|
if (props.selected === 'trigger') return null;
|
||||||
@@ -42,13 +55,67 @@ const isCondition = computed(
|
|||||||
|
|
||||||
const conditionFields = computed(() => conditionFieldsForTrigger(trigger.value));
|
const conditionFields = computed(() => conditionFieldsForTrigger(trigger.value));
|
||||||
|
|
||||||
const conditionOps = computed((): AutomationConditionOp[] => {
|
const conditionOps = computed((): AutomationConditionOp[] =>
|
||||||
const field = selectedStep.value?.conditionField;
|
conditionOpsForField(selectedStep.value?.conditionField),
|
||||||
if (field === 'deal_amount') return ['gte', 'lte', 'eq', 'neq'];
|
);
|
||||||
if (field === 'ticket_priority') return ['eq', 'neq'];
|
|
||||||
return ['is_set', 'is_empty'];
|
const selectedForm = computed(() =>
|
||||||
|
sourceId.value ? props.forms.find((f) => f.id === sourceId.value) ?? null : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const formFields = computed(() =>
|
||||||
|
(selectedForm.value?.fields ?? []).filter((f) => f.fieldType !== 'heading'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const conditionFormField = computed(() => {
|
||||||
|
const key = selectedStep.value?.conditionKey;
|
||||||
|
return key ? formFields.value.find((f) => f.key === key) ?? null : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const needsConditionValue = computed(() => {
|
||||||
|
const op = selectedStep.value?.conditionOp;
|
||||||
|
return op !== 'is_set' && op !== 'is_empty';
|
||||||
|
});
|
||||||
|
|
||||||
|
const assignModes = Object.keys(AUTOMATION_ASSIGN_MODE_LABELS) as AutomationAssignMode[];
|
||||||
|
|
||||||
|
const dueUnits = [
|
||||||
|
{ value: 'now', label: 'همان لحظه (امروز)' },
|
||||||
|
{ value: 'hours', label: 'چند ساعت بعد' },
|
||||||
|
{ value: 'days', label: 'چند روز بعد' },
|
||||||
|
] as const;
|
||||||
|
type DueUnit = (typeof dueUnits)[number]['value'];
|
||||||
|
|
||||||
|
function dueUnitOf(step: AutomationStep): DueUnit {
|
||||||
|
const m = step.dueOffsetMinutes ?? 0;
|
||||||
|
if (!m) return 'now';
|
||||||
|
return m % 1440 === 0 ? 'days' : 'hours';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dueAmountOf(step: AutomationStep): number {
|
||||||
|
const m = step.dueOffsetMinutes ?? 0;
|
||||||
|
if (!m) return 0;
|
||||||
|
return m % 1440 === 0 ? m / 1440 : Math.round(m / 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDue(step: AutomationStep, unit: DueUnit, amount: number) {
|
||||||
|
const n = Math.max(1, Math.trunc(Number(amount) || 1));
|
||||||
|
step.dueOffsetMinutes = unit === 'now' ? 0 : unit === 'days' ? n * 1440 : n * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleUser(step: AutomationStep, id: string, on: boolean) {
|
||||||
|
const cur = new Set(step.assigneeUserIds ?? []);
|
||||||
|
if (on) cur.add(id);
|
||||||
|
else cur.delete(id);
|
||||||
|
step.assigneeUserIds = [...cur];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConditionKey(step: AutomationStep, key: string) {
|
||||||
|
step.conditionKey = key || null;
|
||||||
|
const field = formFields.value.find((f) => f.key === key);
|
||||||
|
step.conditionValue = field?.options?.[0] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
const activityTypes: Array<{ value: ActivityType; label: string }> = [
|
const activityTypes: Array<{ value: ActivityType; label: string }> = [
|
||||||
{ value: 'task', label: 'وظیفه' },
|
{ value: 'task', label: 'وظیفه' },
|
||||||
{ value: 'call', label: 'تماس' },
|
{ value: 'call', label: 'تماس' },
|
||||||
@@ -68,17 +135,22 @@ function setAction(step: AutomationStep, action: AutomationAction) {
|
|||||||
step.action = action;
|
step.action = action;
|
||||||
if (action === 'add_loyalty_points' && !step.points) step.points = 100;
|
if (action === 'add_loyalty_points' && !step.points) step.points = 100;
|
||||||
if (action === 'create_activity' && !step.activityType) step.activityType = 'task';
|
if (action === 'create_activity' && !step.activityType) step.activityType = 'task';
|
||||||
|
if (action === 'create_activity' && !step.assignMode) step.assignMode = 'owner';
|
||||||
if (action === 'create_notification' && !step.notifyChannel) step.notifyChannel = 'app';
|
if (action === 'create_notification' && !step.notifyChannel) step.notifyChannel = 'app';
|
||||||
}
|
}
|
||||||
|
|
||||||
function setConditionField(step: AutomationStep, field: AutomationConditionField) {
|
function setConditionField(step: AutomationStep, field: AutomationConditionField) {
|
||||||
step.conditionField = field;
|
step.conditionField = field;
|
||||||
|
step.conditionKey = null;
|
||||||
if (field === 'deal_amount') {
|
if (field === 'deal_amount') {
|
||||||
step.conditionOp = 'gte';
|
step.conditionOp = 'gte';
|
||||||
step.conditionValue = typeof step.conditionValue === 'number' ? step.conditionValue : 0;
|
step.conditionValue = typeof step.conditionValue === 'number' ? step.conditionValue : 0;
|
||||||
} else if (field === 'ticket_priority') {
|
} else if (field === 'ticket_priority') {
|
||||||
step.conditionOp = 'eq';
|
step.conditionOp = 'eq';
|
||||||
step.conditionValue = 'normal';
|
step.conditionValue = 'normal';
|
||||||
|
} else if (field === 'form_field') {
|
||||||
|
step.conditionOp = 'contains';
|
||||||
|
step.conditionValue = '';
|
||||||
} else {
|
} else {
|
||||||
step.conditionOp = 'is_set';
|
step.conditionOp = 'is_set';
|
||||||
step.conditionValue = undefined;
|
step.conditionValue = undefined;
|
||||||
@@ -115,12 +187,38 @@ watch(
|
|||||||
</optgroup>
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label v-if="trigger === 'lead_submitted'">
|
||||||
|
فرم
|
||||||
|
<select v-model="sourceId">
|
||||||
|
<option :value="null">همه فرمها</option>
|
||||||
|
<option v-for="f in forms" :key="f.id" :value="f.id">{{ f.name }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p v-if="trigger === 'lead_submitted' && !sourceId" class="hint">
|
||||||
|
برای شرط روی پاسخهای فرم، یک فرم مشخص را انتخاب کنید.
|
||||||
|
</p>
|
||||||
|
<label>
|
||||||
|
رفتار شرطها
|
||||||
|
<select v-model="chainMode">
|
||||||
|
<option value="branches">{{ AUTOMATION_CHAIN_MODE_LABELS.branches }}</option>
|
||||||
|
<option value="stop">{{ AUTOMATION_CHAIN_MODE_LABELS.stop }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p v-if="chainMode === 'branches'" class="hint">
|
||||||
|
هر شرط یک شاخه را شروع میکند. شرطهای پشتسرهم با «و» ترکیب میشوند و فقط
|
||||||
|
عملهای بعد از خودشان (تا شرط بعدی) را کنترل میکنند؛ مثلاً اگر دو گزینه تیک بخورد، هر دو
|
||||||
|
شاخه اجرا میشود.
|
||||||
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="selectedStep && isCondition">
|
<template v-else-if="selectedStep && isCondition">
|
||||||
<h2>گره شرط</h2>
|
<h2>گره شرط</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
اگر شرط برقرار نباشد، عملهای بعد از این گره اجرا نمیشوند.
|
{{
|
||||||
|
chainMode === 'branches'
|
||||||
|
? 'اگر شرط برقرار نباشد، فقط عملهای این شاخه (تا شرط بعدی) اجرا نمیشوند.'
|
||||||
|
: 'اگر شرط برقرار نباشد، عملهای بعد از این گره اجرا نمیشوند.'
|
||||||
|
}}
|
||||||
</p>
|
</p>
|
||||||
<label>
|
<label>
|
||||||
فیلد
|
فیلد
|
||||||
@@ -138,6 +236,21 @@ watch(
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<template v-if="selectedStep.conditionField === 'form_field'">
|
||||||
|
<p v-if="!selectedForm" class="hint warn">
|
||||||
|
ابتدا در گره محرک یک فرم مشخص انتخاب کنید.
|
||||||
|
</p>
|
||||||
|
<label v-else>
|
||||||
|
فیلد فرم
|
||||||
|
<select
|
||||||
|
:value="selectedStep.conditionKey ?? ''"
|
||||||
|
@change="setConditionKey(selectedStep, ($event.target as HTMLSelectElement).value)"
|
||||||
|
>
|
||||||
|
<option value="" disabled>انتخاب فیلد…</option>
|
||||||
|
<option v-for="f in formFields" :key="f.key" :value="f.key">{{ f.label }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
<label>
|
<label>
|
||||||
مقایسه
|
مقایسه
|
||||||
<select v-model="selectedStep.conditionOp">
|
<select v-model="selectedStep.conditionOp">
|
||||||
@@ -158,6 +271,18 @@ watch(
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<template v-if="selectedStep.conditionField === 'form_field' && needsConditionValue">
|
||||||
|
<label v-if="conditionFormField?.options?.length">
|
||||||
|
گزینه
|
||||||
|
<select v-model="selectedStep.conditionValue">
|
||||||
|
<option v-for="o in conditionFormField.options" :key="o" :value="o">{{ o }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label v-else>
|
||||||
|
مقدار
|
||||||
|
<input v-model.trim="selectedStep.conditionValue" type="text" maxlength="200" />
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="selectedStep">
|
<template v-else-if="selectedStep">
|
||||||
@@ -207,15 +332,106 @@ watch(
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label v-if="selectedStep.action === 'create_activity'">
|
<template v-if="selectedStep.action === 'create_activity'">
|
||||||
مسئول فعالیت
|
<label v-if="activityTypeDefs.length">
|
||||||
<select v-model="selectedStep.assigneeId">
|
نوع سفارشی (اختیاری)
|
||||||
<option :value="null">مسئول معامله / تیکت</option>
|
<select v-model="selectedStep.activityTypeDefId">
|
||||||
<option v-for="u in users" :key="u.id" :value="u.id">
|
<option :value="null">— بدون نوع سفارشی —</option>
|
||||||
|
<option v-for="t in activityTypeDefs" :key="t.id" :value="t.id">{{ t.name }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
تخصیص به
|
||||||
|
<select v-model="selectedStep.assignMode">
|
||||||
|
<option v-for="m in assignModes" :key="m" :value="m">
|
||||||
|
{{ AUTOMATION_ASSIGN_MODE_LABELS[m] }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label v-if="selectedStep.assignMode === 'user'">
|
||||||
|
کاربر
|
||||||
|
<select v-model="selectedStep.assigneeId">
|
||||||
|
<option :value="null" disabled>انتخاب کاربر…</option>
|
||||||
|
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<template v-else-if="selectedStep.assignMode === 'team_round_robin'">
|
||||||
|
<label>
|
||||||
|
تیم
|
||||||
|
<select v-model="selectedStep.assigneeTeamId">
|
||||||
|
<option :value="null" disabled>انتخاب تیم…</option>
|
||||||
|
<option v-for="t in teams" :key="t.id" :value="t.id">
|
||||||
|
{{ t.name }} ({{ t.memberCount }} نفر)
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p v-if="!teams.length" class="hint warn">
|
||||||
|
هنوز تیمی تعریف نشده؛ از «تنظیمات ← تیمها و سطح دسترسی» بسازید.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<div v-else-if="selectedStep.assignMode === 'users_round_robin'" class="user-picks">
|
||||||
|
<p class="field-label">کاربران</p>
|
||||||
|
<label v-for="u in users" :key="u.id" class="ch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="(selectedStep.assigneeUserIds ?? []).includes(u.id)"
|
||||||
|
@change="toggleUser(selectedStep, u.id, ($event.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
{{ u.fullName }}
|
{{ u.fullName }}
|
||||||
</option>
|
</label>
|
||||||
</select>
|
</div>
|
||||||
</label>
|
<p
|
||||||
|
v-if="selectedStep.assignMode === 'team_round_robin' || selectedStep.assignMode === 'users_round_robin'"
|
||||||
|
class="hint"
|
||||||
|
>
|
||||||
|
هر بار نفر بعدی به نوبت انتخاب میشود تا کارها مساوی تقسیم شوند.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="due">
|
||||||
|
<label>
|
||||||
|
موعد
|
||||||
|
<select
|
||||||
|
:value="dueUnitOf(selectedStep)"
|
||||||
|
@change="
|
||||||
|
setDue(
|
||||||
|
selectedStep,
|
||||||
|
($event.target as HTMLSelectElement).value as DueUnit,
|
||||||
|
dueAmountOf(selectedStep) || 1,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<option v-for="u in dueUnits" :key="u.value" :value="u.value">{{ u.label }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label v-if="dueUnitOf(selectedStep) !== 'now'">
|
||||||
|
تعداد
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:value="dueAmountOf(selectedStep)"
|
||||||
|
@input="
|
||||||
|
setDue(
|
||||||
|
selectedStep,
|
||||||
|
dueUnitOf(selectedStep),
|
||||||
|
Number(($event.target as HTMLInputElement).value),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p v-if="selectedStep.action === 'create_activity' || selectedStep.action === 'create_notification'" class="hint">
|
||||||
|
در عنوان و متن میتوانید از
|
||||||
|
<code v-pre>{{title}}</code>، <code v-pre>{{mobile}}</code>، <code v-pre>{{form}}</code> و
|
||||||
|
<code v-pre>{{field.کلید}}</code> استفاده کنید.
|
||||||
|
<template v-if="formFields.length">
|
||||||
|
کلیدها:
|
||||||
|
<span v-for="(f, i) in formFields" :key="f.key">
|
||||||
|
<code>{{ f.key }}</code> ({{ f.label }})<template v-if="i < formFields.length - 1">، </template>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
|
||||||
<template v-if="selectedStep.action === 'create_notification'">
|
<template v-if="selectedStep.action === 'create_notification'">
|
||||||
<p class="field-label">نحوه ارسال</p>
|
<p class="field-label">نحوه ارسال</p>
|
||||||
@@ -341,4 +557,35 @@ textarea {
|
|||||||
height: 14px;
|
height: 14px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hint.warn {
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint code {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picks {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
max-height: 12rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.due {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.due input {
|
||||||
|
width: 5.5rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -391,6 +391,20 @@ onMounted(() => {
|
|||||||
{{ activity.createdBy?.fullName || '—' }}
|
{{ activity.createdBy?.fullName || '—' }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="activity.lead">
|
||||||
|
<dt>
|
||||||
|
<UiIcon name="target" :size="15" />
|
||||||
|
لید
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
<RouterLink
|
||||||
|
class="lead-link"
|
||||||
|
:to="{ name: 'lead-detail', params: { id: activity.lead.id } }"
|
||||||
|
>
|
||||||
|
{{ activity.lead.title }}
|
||||||
|
</RouterLink>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>
|
<dt>
|
||||||
<UiIcon name="calendar" :size="15" />
|
<UiIcon name="calendar" :size="15" />
|
||||||
@@ -684,6 +698,16 @@ dd {
|
|||||||
text-align: start;
|
text-align: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lead-link {
|
||||||
|
color: var(--accent, #0f766e);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.person {
|
.person {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -14,12 +14,17 @@ import type { AutomationAddPayload } from '@/components/automations/AutomationCa
|
|||||||
import AutomationInspector from '@/components/automations/AutomationInspector.vue';
|
import AutomationInspector from '@/components/automations/AutomationInspector.vue';
|
||||||
import { toFaDigits } from '@/lib/jalali';
|
import { toFaDigits } from '@/lib/jalali';
|
||||||
import type {
|
import type {
|
||||||
|
ActivityTypeDefDto,
|
||||||
AutomationAction,
|
AutomationAction,
|
||||||
|
AutomationChainMode,
|
||||||
|
AutomationConditionField,
|
||||||
AutomationRuleDto,
|
AutomationRuleDto,
|
||||||
AutomationStep,
|
AutomationStep,
|
||||||
AutomationTrigger,
|
AutomationTrigger,
|
||||||
CreateAutomationRuleRequest,
|
CreateAutomationRuleRequest,
|
||||||
|
LeadFormDto,
|
||||||
OrgUserDto,
|
OrgUserDto,
|
||||||
|
TeamDto,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import {
|
import {
|
||||||
AUTOMATION_ACTION_LABELS,
|
AUTOMATION_ACTION_LABELS,
|
||||||
@@ -38,16 +43,26 @@ const error = ref('');
|
|||||||
const ok = ref('');
|
const ok = ref('');
|
||||||
const items = ref<AutomationRuleDto[]>([]);
|
const items = ref<AutomationRuleDto[]>([]);
|
||||||
const users = ref<OrgUserDto[]>([]);
|
const users = ref<OrgUserDto[]>([]);
|
||||||
|
const forms = ref<LeadFormDto[]>([]);
|
||||||
|
const teams = ref<TeamDto[]>([]);
|
||||||
|
const activityTypeDefs = ref<ActivityTypeDefDto[]>([]);
|
||||||
|
|
||||||
const editingId = ref<string | null>(null);
|
const editingId = ref<string | null>(null);
|
||||||
const name = ref('قانون بدون نام');
|
const name = ref('قانون بدون نام');
|
||||||
const trigger = ref<AutomationTrigger>('deal_won');
|
const trigger = ref<AutomationTrigger>('deal_won');
|
||||||
const isActive = ref(true);
|
const isActive = ref(true);
|
||||||
|
const sourceId = ref<string | null>(null);
|
||||||
|
const chainMode = ref<AutomationChainMode>('branches');
|
||||||
const steps = ref<AutomationStep[]>([makeStep('create_activity')]);
|
const steps = ref<AutomationStep[]>([makeStep('create_activity')]);
|
||||||
const selected = ref<'trigger' | string>('trigger');
|
const selected = ref<'trigger' | string>('trigger');
|
||||||
const savedSnap = ref('');
|
const savedSnap = ref('');
|
||||||
|
|
||||||
const dirty = computed(() => snap() !== savedSnap.value);
|
const dirty = computed(() => snap() !== savedSnap.value);
|
||||||
|
const selectedForm = computed(() =>
|
||||||
|
trigger.value === 'lead_submitted' && sourceId.value
|
||||||
|
? forms.value.find((f) => f.id === sourceId.value) ?? null
|
||||||
|
: null,
|
||||||
|
);
|
||||||
const canSave = computed(
|
const canSave = computed(
|
||||||
() => name.value.trim().length >= 2 && Boolean(firstAutomationAction(steps.value)),
|
() => name.value.trim().length >= 2 && Boolean(firstAutomationAction(steps.value)),
|
||||||
);
|
);
|
||||||
@@ -63,19 +78,29 @@ function makeStep(action: AutomationAction): AutomationStep {
|
|||||||
notifyTarget: action === 'create_notification' ? 'staff' : undefined,
|
notifyTarget: action === 'create_notification' ? 'staff' : undefined,
|
||||||
assigneeId: null,
|
assigneeId: null,
|
||||||
notifyUserId: null,
|
notifyUserId: null,
|
||||||
|
assignMode: action === 'create_activity' ? 'owner' : undefined,
|
||||||
|
dueOffsetMinutes: action === 'create_activity' ? 0 : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function conditionDefaults(field: AutomationConditionField): Partial<AutomationStep> {
|
||||||
|
if (field === 'deal_amount') return { conditionOp: 'gte', conditionValue: 0, conditionKey: null };
|
||||||
|
if (field === 'ticket_priority') {
|
||||||
|
return { conditionOp: 'eq', conditionValue: 'normal', conditionKey: null };
|
||||||
|
}
|
||||||
|
if (field === 'form_field') {
|
||||||
|
return { conditionOp: 'contains', conditionValue: '', conditionKey: null };
|
||||||
|
}
|
||||||
|
return { conditionOp: 'is_set', conditionValue: undefined, conditionKey: null };
|
||||||
|
}
|
||||||
|
|
||||||
function makeCondition(): AutomationStep {
|
function makeCondition(): AutomationStep {
|
||||||
const field = conditionFieldsForTrigger(trigger.value)[0] ?? 'has_person';
|
const field = conditionFieldsForTrigger(trigger.value)[0] ?? 'has_person';
|
||||||
return {
|
return {
|
||||||
id: `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
id: `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
kind: 'condition',
|
kind: 'condition',
|
||||||
conditionField: field,
|
conditionField: field,
|
||||||
conditionOp:
|
...conditionDefaults(field),
|
||||||
field === 'deal_amount' ? 'gte' : field === 'ticket_priority' ? 'eq' : 'is_set',
|
|
||||||
conditionValue:
|
|
||||||
field === 'deal_amount' ? 0 : field === 'ticket_priority' ? 'normal' : undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +109,8 @@ function snap() {
|
|||||||
name: name.value.trim(),
|
name: name.value.trim(),
|
||||||
trigger: trigger.value,
|
trigger: trigger.value,
|
||||||
isActive: isActive.value,
|
isActive: isActive.value,
|
||||||
|
sourceId: sourceId.value,
|
||||||
|
chainMode: chainMode.value,
|
||||||
steps: steps.value,
|
steps: steps.value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -106,6 +133,8 @@ function hydrate(row?: AutomationRuleDto | null) {
|
|||||||
name.value = 'قانون بدون نام';
|
name.value = 'قانون بدون نام';
|
||||||
trigger.value = 'deal_won';
|
trigger.value = 'deal_won';
|
||||||
isActive.value = true;
|
isActive.value = true;
|
||||||
|
sourceId.value = null;
|
||||||
|
chainMode.value = 'branches';
|
||||||
steps.value = [makeStep('create_activity')];
|
steps.value = [makeStep('create_activity')];
|
||||||
selected.value = 'trigger';
|
selected.value = 'trigger';
|
||||||
markClean();
|
markClean();
|
||||||
@@ -115,6 +144,8 @@ function hydrate(row?: AutomationRuleDto | null) {
|
|||||||
name.value = row.name;
|
name.value = row.name;
|
||||||
trigger.value = row.trigger;
|
trigger.value = row.trigger;
|
||||||
isActive.value = row.isActive;
|
isActive.value = row.isActive;
|
||||||
|
sourceId.value = row.config.sourceId ?? null;
|
||||||
|
chainMode.value = row.config.chainMode ?? 'stop';
|
||||||
steps.value = normalizeAutomationSteps(row.action, row.config).map((s, i) => ({
|
steps.value = normalizeAutomationSteps(row.action, row.config).map((s, i) => ({
|
||||||
...s,
|
...s,
|
||||||
id: s.id || `step-${i}`,
|
id: s.id || `step-${i}`,
|
||||||
@@ -130,6 +161,14 @@ function hydrate(row?: AutomationRuleDto | null) {
|
|||||||
activityType:
|
activityType:
|
||||||
s.activityType ?? (s.action === 'create_activity' ? 'task' : undefined),
|
s.activityType ?? (s.action === 'create_activity' ? 'task' : undefined),
|
||||||
points: s.points ?? (s.action === 'add_loyalty_points' ? 100 : undefined),
|
points: s.points ?? (s.action === 'add_loyalty_points' ? 100 : undefined),
|
||||||
|
assignMode:
|
||||||
|
s.assignMode ??
|
||||||
|
(s.action === 'create_activity' ? (s.assigneeId ? 'user' : 'owner') : undefined),
|
||||||
|
assigneeTeamId: s.assigneeTeamId ?? null,
|
||||||
|
assigneeUserIds: s.assigneeUserIds ?? [],
|
||||||
|
dueOffsetMinutes: s.dueOffsetMinutes ?? null,
|
||||||
|
activityTypeDefId: s.activityTypeDefId ?? null,
|
||||||
|
conditionKey: s.conditionKey ?? null,
|
||||||
}));
|
}));
|
||||||
if (!steps.value.length) steps.value = [makeStep('create_activity')];
|
if (!steps.value.length) steps.value = [makeStep('create_activity')];
|
||||||
selected.value = 'trigger';
|
selected.value = 'trigger';
|
||||||
@@ -190,12 +229,20 @@ async function load() {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
try {
|
||||||
const [rulesRes, usersRes] = await Promise.all([
|
const [rulesRes, usersRes, formsRes, teamsRes, typesRes] = await Promise.all([
|
||||||
api.get<AutomationRuleDto[]>('/automations'),
|
api.get<AutomationRuleDto[]>('/automations'),
|
||||||
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
|
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
|
||||||
|
api.get<LeadFormDto[]>('/lead-forms').catch(() => ({ data: [] as LeadFormDto[] })),
|
||||||
|
api.get<TeamDto[]>('/teams').catch(() => ({ data: [] as TeamDto[] })),
|
||||||
|
api
|
||||||
|
.get<ActivityTypeDefDto[]>('/activities/types')
|
||||||
|
.catch(() => ({ data: [] as ActivityTypeDefDto[] })),
|
||||||
]);
|
]);
|
||||||
items.value = rulesRes.data;
|
items.value = rulesRes.data;
|
||||||
users.value = usersRes.data;
|
users.value = usersRes.data;
|
||||||
|
forms.value = formsRes.data;
|
||||||
|
teams.value = teamsRes.data;
|
||||||
|
activityTypeDefs.value = typesRes.data.filter((t) => t.isActive && !t.isSystem);
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
const cur = items.value.find((x) => x.id === editingId.value);
|
const cur = items.value.find((x) => x.id === editingId.value);
|
||||||
if (cur && !dirty.value) hydrate(cur);
|
if (cur && !dirty.value) hydrate(cur);
|
||||||
@@ -223,7 +270,12 @@ async function save() {
|
|||||||
trigger: trigger.value,
|
trigger: trigger.value,
|
||||||
action,
|
action,
|
||||||
isActive: isActive.value,
|
isActive: isActive.value,
|
||||||
config: packAutomationConfig(steps.value),
|
config: packAutomationConfig(steps.value, {
|
||||||
|
sourceId: trigger.value === 'lead_submitted' || trigger.value === 'sms_link_clicked'
|
||||||
|
? sourceId.value
|
||||||
|
: null,
|
||||||
|
chainMode: chainMode.value,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
await api.patch(`/automations/${editingId.value}`, payload);
|
await api.patch(`/automations/${editingId.value}`, payload);
|
||||||
@@ -269,25 +321,15 @@ onMounted(async () => {
|
|||||||
hydrate(items.value[0] ?? null);
|
hydrate(items.value[0] ?? null);
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(trigger, (next) => {
|
watch(trigger, (next, prev) => {
|
||||||
|
if (prev === 'lead_submitted' && next !== 'lead_submitted') sourceId.value = null;
|
||||||
const allowed = conditionFieldsForTrigger(next);
|
const allowed = conditionFieldsForTrigger(next);
|
||||||
const fallback = allowed[0] ?? 'has_person';
|
const fallback = allowed[0] ?? 'has_person';
|
||||||
for (const step of steps.value) {
|
for (const step of steps.value) {
|
||||||
if (!isAutomationCondition(step)) continue;
|
if (!isAutomationCondition(step)) continue;
|
||||||
if (step.conditionField && allowed.includes(step.conditionField)) continue;
|
if (step.conditionField && allowed.includes(step.conditionField)) continue;
|
||||||
step.conditionField = fallback;
|
step.conditionField = fallback;
|
||||||
step.conditionOp =
|
Object.assign(step, conditionDefaults(fallback));
|
||||||
fallback === 'deal_amount'
|
|
||||||
? 'gte'
|
|
||||||
: fallback === 'ticket_priority'
|
|
||||||
? 'eq'
|
|
||||||
: 'is_set';
|
|
||||||
step.conditionValue =
|
|
||||||
fallback === 'deal_amount'
|
|
||||||
? 0
|
|
||||||
: fallback === 'ticket_priority'
|
|
||||||
? 'normal'
|
|
||||||
: undefined;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -372,6 +414,10 @@ watch(trigger, (next) => {
|
|||||||
:trigger="trigger"
|
:trigger="trigger"
|
||||||
:steps="steps"
|
:steps="steps"
|
||||||
:selected="selected"
|
:selected="selected"
|
||||||
|
:chain-mode="chainMode"
|
||||||
|
:form="selectedForm"
|
||||||
|
:users="users"
|
||||||
|
:teams="teams"
|
||||||
@select="selected = $event"
|
@select="selected = $event"
|
||||||
@add="addStep"
|
@add="addStep"
|
||||||
@remove="removeStep"
|
@remove="removeStep"
|
||||||
@@ -379,8 +425,13 @@ watch(trigger, (next) => {
|
|||||||
<AutomationInspector
|
<AutomationInspector
|
||||||
v-model:trigger="trigger"
|
v-model:trigger="trigger"
|
||||||
v-model:steps="steps"
|
v-model:steps="steps"
|
||||||
|
v-model:source-id="sourceId"
|
||||||
|
v-model:chain-mode="chainMode"
|
||||||
:selected="selected"
|
:selected="selected"
|
||||||
:users="users"
|
:users="users"
|
||||||
|
:forms="forms"
|
||||||
|
:teams="teams"
|
||||||
|
:activity-type-defs="activityTypeDefs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,12 +17,15 @@ import {
|
|||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
|
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
|
||||||
import type {
|
import type {
|
||||||
|
ActivityDto,
|
||||||
ConvertLeadResult,
|
ConvertLeadResult,
|
||||||
LeadDto,
|
LeadDto,
|
||||||
LeadEventDto,
|
LeadEventDto,
|
||||||
LeadStageDto,
|
LeadStageDto,
|
||||||
OrgUserDto,
|
OrgUserDto,
|
||||||
|
PaginatedResponse,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
|
import { ACTIVITY_TYPE_LABELS } from '@mento/shared';
|
||||||
|
|
||||||
type LeadDetail = LeadDto & { events: LeadEventDto[] };
|
type LeadDetail = LeadDto & { events: LeadEventDto[] };
|
||||||
|
|
||||||
@@ -39,6 +42,7 @@ const okMsg = ref('');
|
|||||||
const lead = ref<LeadDetail | null>(null);
|
const lead = ref<LeadDetail | null>(null);
|
||||||
const stages = ref<LeadStageDto[]>([]);
|
const stages = ref<LeadStageDto[]>([]);
|
||||||
const users = ref<OrgUserDto[]>([]);
|
const users = ref<OrgUserDto[]>([]);
|
||||||
|
const activities = ref<ActivityDto[]>([]);
|
||||||
|
|
||||||
const stageId = ref('');
|
const stageId = ref('');
|
||||||
const assigneeId = ref('');
|
const assigneeId = ref('');
|
||||||
@@ -85,12 +89,18 @@ async function load() {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
try {
|
||||||
const [leadRes, stagesRes, usersRes] = await Promise.all([
|
const [leadRes, stagesRes, usersRes, actRes] = await Promise.all([
|
||||||
api.get<LeadDetail>(`/leads/${id.value}`),
|
api.get<LeadDetail>(`/leads/${id.value}`),
|
||||||
api.get<LeadStageDto[]>('/leads/stages'),
|
api.get<LeadStageDto[]>('/leads/stages'),
|
||||||
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
|
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
|
||||||
|
api
|
||||||
|
.get<PaginatedResponse<ActivityDto>>('/activities', {
|
||||||
|
params: { filter: 'all', leadId: id.value, limit: 30 },
|
||||||
|
})
|
||||||
|
.catch(() => null),
|
||||||
]);
|
]);
|
||||||
lead.value = leadRes.data;
|
lead.value = leadRes.data;
|
||||||
|
activities.value = actRes?.data.items ?? [];
|
||||||
stages.value = stagesRes.data;
|
stages.value = stagesRes.data;
|
||||||
users.value = usersRes.data.filter((u) => u.isActive !== false);
|
users.value = usersRes.data.filter((u) => u.isActive !== false);
|
||||||
stageId.value = leadRes.data.stageId;
|
stageId.value = leadRes.data.stageId;
|
||||||
@@ -394,6 +404,22 @@ onMounted(load);
|
|||||||
|
|
||||||
<RelatedTasksPanel :entity="{ type: 'lead', id: lead.id, label: lead.title }" />
|
<RelatedTasksPanel :entity="{ type: 'lead', id: lead.id, label: lead.title }" />
|
||||||
|
|
||||||
|
<UiCard v-if="activities.length" title="فعالیتهای مرتبط">
|
||||||
|
<ul class="lead-acts">
|
||||||
|
<li v-for="a in activities" :key="a.id">
|
||||||
|
<RouterLink :to="{ name: 'activity-detail', params: { id: a.id } }">
|
||||||
|
<strong>{{ a.title }}</strong>
|
||||||
|
</RouterLink>
|
||||||
|
<span class="muted">
|
||||||
|
{{ a.typeDef?.name || ACTIVITY_TYPE_LABELS[a.type] }}
|
||||||
|
· {{ a.assignee?.fullName || 'بدون مسئول' }}
|
||||||
|
· {{ formatDate(a.dueAt) }}
|
||||||
|
· {{ a.status === 'done' ? 'انجام شد' : a.status === 'canceled' ? 'لغو شده' : 'باز' }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</UiCard>
|
||||||
|
|
||||||
<UiCard title="جدول زمانی">
|
<UiCard title="جدول زمانی">
|
||||||
<ul v-if="lead.events.length" class="timeline">
|
<ul v-if="lead.events.length" class="timeline">
|
||||||
<li v-for="ev in lead.events" :key="ev.id">
|
<li v-for="ev in lead.events" :key="ev.id">
|
||||||
@@ -538,6 +564,39 @@ onMounted(load);
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lead-acts {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-acts li {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding-bottom: 0.55rem;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-acts li:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-acts a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-acts a:hover strong {
|
||||||
|
color: var(--accent, #0f766e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-acts .muted {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
.timeline {
|
.timeline {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -646,6 +646,36 @@
|
|||||||
مثال: معامله برنده شد → فعالیت پیگیری ساخته شود، اعلان برود یا امتیاز باشگاه داده شود.
|
مثال: معامله برنده شد → فعالیت پیگیری ساخته شود، اعلان برود یا امتیاز باشگاه داده شود.
|
||||||
یک محرک مهم: **کلیک روی لینک پیامک**.
|
یک محرک مهم: **کلیک روی لینک پیامک**.
|
||||||
|
|
||||||
|
#### اتوماسیون بر اساس پاسخ فرم (شاخهای)
|
||||||
|
محرک **«ثبت لید از فرم»** را انتخاب کنید. در همان پنل محرک میتوانید:
|
||||||
|
- **فرم** را مشخص کنید تا قانون فقط برای همان فرم اجرا شود (یا «همه فرمها»).
|
||||||
|
- **رفتار شرطها** را تعیین کنید:
|
||||||
|
- **شاخهای (پیشنهادی):** هر شرط فقط اقدامهای بعد از خودش را تا شرط بعدی کنترل میکند. چند شرطِ پشتسرهم با «و» ترکیب میشوند.
|
||||||
|
- **توقف:** همان رفتار قبلی؛ اولین شرطی که برقرار نباشد کل زنجیره را متوقف میکند.
|
||||||
|
|
||||||
|
شرط **«پاسخ فیلد فرم»** یک فیلد از فرم را میگیرد و با عملگرهای «شامل / شامل نیست / برابر / نابرابر / پر شده / خالی» بررسی میکند. برای فیلدهای چندانتخابی (چکباکس) از **«شامل»** استفاده کنید.
|
||||||
|
|
||||||
|
اقدام **«ساخت فعالیت»** این تنظیمات را دارد:
|
||||||
|
- **نوع فعالیت:** نوع پایه یا نوع سفارشی.
|
||||||
|
- **واگذاری:**
|
||||||
|
- مسئول پرونده
|
||||||
|
- یک کاربر مشخص
|
||||||
|
- **نوبتی بین اعضای یک تیم**
|
||||||
|
- **نوبتی بین چند کاربر انتخابی**
|
||||||
|
|
||||||
|
در حالت نوبتی، کارها بهترتیب و مساوی بین افراد پخش میشوند.
|
||||||
|
- **سررسید:** همان لحظه، یا چند ساعت یا چند روز بعد.
|
||||||
|
- **متغیرها در عنوان و توضیح:** `{{title}}`، `{{mobile}}`، `{{form}}` و `{{field.کلید_فیلد}}`.
|
||||||
|
|
||||||
|
پاسخهای فرم خودکار در توضیح فعالیت میآید. فعالیت به لید وصل میشود: در جزئیات فعالیت لینک «لید» دیده میشود و در صفحهٔ لید بخش «فعالیتهای مرتبط» نمایش داده میشود.
|
||||||
|
|
||||||
|
**مثال (فرم «درخواست شروع همکاری با BSJ»، فیلد «نوع همکاری»):**
|
||||||
|
1. شرط: نوع همکاری **شامل** «بنکداری» ← اقدام: فعالیت تماس برای کاربر الف، سررسید همان لحظه.
|
||||||
|
2. شرط: نوع همکاری **شامل** «آنلاین شاپ» ← اقدام: فعالیت تماس برای کاربر ب.
|
||||||
|
3. شرط: نوع همکاری **شامل** «ویترین نقره» ← اقدام: فعالیت تماس، نوبتی بین اعضای تیم «فروش تلفنی».
|
||||||
|
|
||||||
|
اگر چند گزینه با هم تیک بخورد، فعالیت هر گزینه جداگانه ساخته میشود.
|
||||||
|
|
||||||
### اتوماسیون پیامکها
|
### اتوماسیون پیامکها
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -363,6 +363,13 @@
|
|||||||
### اتوماسیون گردش کار (اگر/آنگاه)
|
### اتوماسیون گردش کار (اگر/آنگاه)
|
||||||
- محرکها: برنده شدن معامله، حل تیکت، **کلیک روی لینک پیامک** و …
|
- محرکها: برنده شدن معامله، حل تیکت، **کلیک روی لینک پیامک** و …
|
||||||
- اقدامها: ساخت فعالیت، اعلان، امتیاز باشگاه، …
|
- اقدامها: ساخت فعالیت، اعلان، امتیاز باشگاه، …
|
||||||
|
- محرک **ثبت لید از فرم** با فیلتر روی یک فرم مشخص
|
||||||
|
- شرط **پاسخ فیلد فرم** (شامل / شامل نیست / برابر / پر یا خالی). از چکباکسهای چندانتخابی پشتیبانی میکند.
|
||||||
|
- **حالت شاخهای:** هر شرط فقط اقدامهای بعد از خودش را کنترل میکند، پس یک قانون میتواند چند مسیر داشته باشد.
|
||||||
|
- واگذاری فعالیت: مسئول پرونده، کاربر ثابت، **نوبتی مساوی بین اعضای تیم** یا بین کاربران انتخابی
|
||||||
|
- سررسید نسبی (همان لحظه / چند ساعت یا چند روز بعد) و نوع فعالیت سفارشی
|
||||||
|
- متغیرهای `{{title}}`، `{{mobile}}`، `{{form}}` و `{{field.key}}` در عنوان و توضیح، بههمراه درج خودکار پاسخهای فرم
|
||||||
|
- اتصال فعالیت به لید (لینک در فعالیت و فهرست «فعالیتهای مرتبط» در لید)
|
||||||
|
|
||||||
### اتوماسیون پیامک
|
### اتوماسیون پیامک
|
||||||
- ارسال پیامک/اعلان با **تأخیر** و **تکرار**
|
- ارسال پیامک/اعلان با **تأخیر** و **تکرار**
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -147,6 +147,7 @@
|
|||||||
| [x] | تهحساب پیشرفته | نمای سند شبیه فاکتور با برچسب فلز، دادهٔ خام، ورود گروهی مشتریان |
|
| [x] | تهحساب پیشرفته | نمای سند شبیه فاکتور با برچسب فلز، دادهٔ خام، ورود گروهی مشتریان |
|
||||||
| [x] | گزارشهای عمیق | گزارشساز، ذخیرهٔ نما، لینک اشتراک، تبدیل مرحلهای، مقایسهٔ دوره، drill-down، دلایل شکست، منبع ورود |
|
| [x] | گزارشهای عمیق | گزارشساز، ذخیرهٔ نما، لینک اشتراک، تبدیل مرحلهای، مقایسهٔ دوره، drill-down، دلایل شکست، منبع ورود |
|
||||||
| [x] | لینک ردیابی پیامک | لینک اختصاصی هر گیرنده، گزارش کلیک، محرک اتوماسیون |
|
| [x] | لینک ردیابی پیامک | لینک اختصاصی هر گیرنده، گزارش کلیک، محرک اتوماسیون |
|
||||||
|
| [x] | اتوماسیون شاخهای فرم | فیلتر فرم، شرط پاسخ فیلد (چندانتخابی)، حالت شاخهای، واگذاری نوبتی تیم/کاربران، سررسید نسبی، اتصال فعالیت به لید |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -1415,6 +1415,8 @@ export interface ActivityDto {
|
|||||||
person: PersonSummaryDto | null;
|
person: PersonSummaryDto | null;
|
||||||
company: CompanySummaryDto | null;
|
company: CompanySummaryDto | null;
|
||||||
deal: DealSummaryDto | null;
|
deal: DealSummaryDto | null;
|
||||||
|
/** لید مرتبط (مثلاً فعالیت ساختهشده از فرم) */
|
||||||
|
lead?: { id: string; title: string } | null;
|
||||||
/** مسئول اصلی (اولین مسئول) — سازگاری با فیلترها و یادآور */
|
/** مسئول اصلی (اولین مسئول) — سازگاری با فیلترها و یادآور */
|
||||||
assignee: UserSummaryDto | null;
|
assignee: UserSummaryDto | null;
|
||||||
/** همه مسئولان انجام فعالیت */
|
/** همه مسئولان انجام فعالیت */
|
||||||
@@ -3103,7 +3105,7 @@ export const AUTOMATION_ACTION_LABELS: Record<AutomationAction, string> = {
|
|||||||
add_loyalty_points: "افزودن امتیاز باشگاه",
|
add_loyalty_points: "افزودن امتیاز باشگاه",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MAX_AUTOMATION_STEPS = 8;
|
export const MAX_AUTOMATION_STEPS = 12;
|
||||||
|
|
||||||
export const AUTOMATION_ACTIONS = [
|
export const AUTOMATION_ACTIONS = [
|
||||||
"create_activity",
|
"create_activity",
|
||||||
@@ -3147,7 +3149,8 @@ export type AutomationConditionField =
|
|||||||
| "deal_amount"
|
| "deal_amount"
|
||||||
| "has_person"
|
| "has_person"
|
||||||
| "has_company"
|
| "has_company"
|
||||||
| "ticket_priority";
|
| "ticket_priority"
|
||||||
|
| "form_field";
|
||||||
|
|
||||||
export type AutomationConditionOp =
|
export type AutomationConditionOp =
|
||||||
| "gte"
|
| "gte"
|
||||||
@@ -3155,7 +3158,30 @@ export type AutomationConditionOp =
|
|||||||
| "eq"
|
| "eq"
|
||||||
| "neq"
|
| "neq"
|
||||||
| "is_set"
|
| "is_set"
|
||||||
| "is_empty";
|
| "is_empty"
|
||||||
|
| "contains"
|
||||||
|
| "not_contains";
|
||||||
|
|
||||||
|
/** stop: شرط ناموفق کل زنجیره را متوقف میکند — branches: هر شرط فقط عملهای بعدی خودش را تا شرط بعدی کنترل میکند */
|
||||||
|
export type AutomationChainMode = "stop" | "branches";
|
||||||
|
|
||||||
|
export type AutomationAssignMode =
|
||||||
|
| "owner"
|
||||||
|
| "user"
|
||||||
|
| "team_round_robin"
|
||||||
|
| "users_round_robin";
|
||||||
|
|
||||||
|
export const AUTOMATION_ASSIGN_MODE_LABELS: Record<AutomationAssignMode, string> = {
|
||||||
|
owner: "مسئول پیشفرض (مسئول پرونده)",
|
||||||
|
user: "کاربر مشخص",
|
||||||
|
team_round_robin: "نوبتی بین اعضای یک تیم",
|
||||||
|
users_round_robin: "نوبتی بین کاربران انتخابی",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AUTOMATION_CHAIN_MODE_LABELS: Record<AutomationChainMode, string> = {
|
||||||
|
branches: "شاخهای — هر شرط فقط عملهای بعد از خودش را کنترل میکند",
|
||||||
|
stop: "توقف — شرط ناموفق کل زنجیره را متوقف میکند",
|
||||||
|
};
|
||||||
|
|
||||||
export type AutomationNotifyChannel = "app" | "sms" | "both";
|
export type AutomationNotifyChannel = "app" | "sms" | "both";
|
||||||
export type AutomationNotifyTarget = "staff" | "person";
|
export type AutomationNotifyTarget = "staff" | "person";
|
||||||
@@ -3168,6 +3194,7 @@ export const AUTOMATION_CONDITION_FIELD_LABELS: Record<
|
|||||||
has_person: "مخاطب دارد",
|
has_person: "مخاطب دارد",
|
||||||
has_company: "شرکت دارد",
|
has_company: "شرکت دارد",
|
||||||
ticket_priority: "اولویت تیکت",
|
ticket_priority: "اولویت تیکت",
|
||||||
|
form_field: "پاسخ فیلد فرم",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AUTOMATION_CONDITION_OP_LABELS: Record<
|
export const AUTOMATION_CONDITION_OP_LABELS: Record<
|
||||||
@@ -3180,6 +3207,8 @@ export const AUTOMATION_CONDITION_OP_LABELS: Record<
|
|||||||
neq: "نامساوی",
|
neq: "نامساوی",
|
||||||
is_set: "دارد",
|
is_set: "دارد",
|
||||||
is_empty: "ندارد",
|
is_empty: "ندارد",
|
||||||
|
contains: "شامل",
|
||||||
|
not_contains: "شامل نیست",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AUTOMATION_NOTIFY_CHANNEL_LABELS: Record<
|
export const AUTOMATION_NOTIFY_CHANNEL_LABELS: Record<
|
||||||
@@ -3222,9 +3251,23 @@ export function conditionFieldsForTrigger(
|
|||||||
if (trigger === "ticket_resolved" || trigger === "ticket_created") {
|
if (trigger === "ticket_resolved" || trigger === "ticket_created") {
|
||||||
return ["ticket_priority", "has_person", "has_company"];
|
return ["ticket_priority", "has_person", "has_company"];
|
||||||
}
|
}
|
||||||
|
if (trigger === "lead_submitted") {
|
||||||
|
return ["form_field", "has_person", "has_company"];
|
||||||
|
}
|
||||||
return ["has_person", "has_company"];
|
return ["has_person", "has_company"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function conditionOpsForField(
|
||||||
|
field: AutomationConditionField | undefined,
|
||||||
|
): AutomationConditionOp[] {
|
||||||
|
if (field === "deal_amount") return ["gte", "lte", "eq", "neq"];
|
||||||
|
if (field === "ticket_priority") return ["eq", "neq"];
|
||||||
|
if (field === "form_field") {
|
||||||
|
return ["contains", "not_contains", "eq", "neq", "is_set", "is_empty"];
|
||||||
|
}
|
||||||
|
return ["is_set", "is_empty"];
|
||||||
|
}
|
||||||
|
|
||||||
export interface AutomationStep {
|
export interface AutomationStep {
|
||||||
id: string;
|
id: string;
|
||||||
kind?: AutomationStepKind;
|
kind?: AutomationStepKind;
|
||||||
@@ -3240,6 +3283,14 @@ export interface AutomationStep {
|
|||||||
conditionField?: AutomationConditionField;
|
conditionField?: AutomationConditionField;
|
||||||
conditionOp?: AutomationConditionOp;
|
conditionOp?: AutomationConditionOp;
|
||||||
conditionValue?: string | number;
|
conditionValue?: string | number;
|
||||||
|
/** کلید فیلد فرم برای شرط form_field */
|
||||||
|
conditionKey?: string | null;
|
||||||
|
assignMode?: AutomationAssignMode;
|
||||||
|
assigneeTeamId?: string | null;
|
||||||
|
assigneeUserIds?: string[];
|
||||||
|
/** موعد فعالیت نسبت به زمان اجرا (دقیقه)؛ ۰ یا خالی = همان لحظه */
|
||||||
|
dueOffsetMinutes?: number | null;
|
||||||
|
activityTypeDefId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AutomationRuleConfig {
|
export interface AutomationRuleConfig {
|
||||||
@@ -3251,8 +3302,15 @@ export interface AutomationRuleConfig {
|
|||||||
notifyTarget?: AutomationNotifyTarget;
|
notifyTarget?: AutomationNotifyTarget;
|
||||||
activityType?: "call" | "meeting" | "task" | "note";
|
activityType?: "call" | "meeting" | "task" | "note";
|
||||||
points?: number;
|
points?: number;
|
||||||
/** فیلتر اختیاری: فقط برای منبع خاص (مثلاً id کمپین SMS) */
|
assignMode?: AutomationAssignMode;
|
||||||
|
assigneeTeamId?: string | null;
|
||||||
|
assigneeUserIds?: string[];
|
||||||
|
dueOffsetMinutes?: number | null;
|
||||||
|
activityTypeDefId?: string | null;
|
||||||
|
/** فیلتر اختیاری: فقط برای منبع خاص (مثلاً id کمپین SMS یا id فرم سرنخ) */
|
||||||
sourceId?: string | null;
|
sourceId?: string | null;
|
||||||
|
/** نبودِ مقدار = stop (رفتار قدیمی) */
|
||||||
|
chainMode?: AutomationChainMode;
|
||||||
/** زنجیرهٔ اعمال؛ اگر خالی باشد همان action تکی استفاده میشود */
|
/** زنجیرهٔ اعمال؛ اگر خالی باشد همان action تکی استفاده میشود */
|
||||||
steps?: AutomationStep[];
|
steps?: AutomationStep[];
|
||||||
}
|
}
|
||||||
@@ -3301,6 +3359,12 @@ export function normalizeAutomationSteps(
|
|||||||
conditionField: s.conditionField,
|
conditionField: s.conditionField,
|
||||||
conditionOp: s.conditionOp,
|
conditionOp: s.conditionOp,
|
||||||
conditionValue: s.conditionValue,
|
conditionValue: s.conditionValue,
|
||||||
|
conditionKey: s.conditionKey,
|
||||||
|
assignMode: s.assignMode,
|
||||||
|
assigneeTeamId: s.assigneeTeamId,
|
||||||
|
assigneeUserIds: Array.isArray(s.assigneeUserIds) ? s.assigneeUserIds : undefined,
|
||||||
|
dueOffsetMinutes: s.dueOffsetMinutes,
|
||||||
|
activityTypeDefId: s.activityTypeDefId,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
@@ -3316,11 +3380,19 @@ export function normalizeAutomationSteps(
|
|||||||
notifyTarget: cfg.notifyTarget,
|
notifyTarget: cfg.notifyTarget,
|
||||||
activityType: cfg.activityType,
|
activityType: cfg.activityType,
|
||||||
points: cfg.points,
|
points: cfg.points,
|
||||||
|
assignMode: cfg.assignMode,
|
||||||
|
assigneeTeamId: cfg.assigneeTeamId,
|
||||||
|
assigneeUserIds: cfg.assigneeUserIds,
|
||||||
|
dueOffsetMinutes: cfg.dueOffsetMinutes,
|
||||||
|
activityTypeDefId: cfg.activityTypeDefId,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function packAutomationConfig(steps: AutomationStep[]): AutomationRuleConfig {
|
export function packAutomationConfig(
|
||||||
|
steps: AutomationStep[],
|
||||||
|
extra: Pick<AutomationRuleConfig, "sourceId" | "chainMode"> = {},
|
||||||
|
): AutomationRuleConfig {
|
||||||
const first =
|
const first =
|
||||||
steps.find((s) => (s.kind ?? "action") !== "condition") ?? steps[0];
|
steps.find((s) => (s.kind ?? "action") !== "condition") ?? steps[0];
|
||||||
return {
|
return {
|
||||||
@@ -3333,6 +3405,8 @@ export function packAutomationConfig(steps: AutomationStep[]): AutomationRuleCon
|
|||||||
notifyTarget: first?.notifyTarget,
|
notifyTarget: first?.notifyTarget,
|
||||||
activityType: first?.activityType,
|
activityType: first?.activityType,
|
||||||
points: first?.points,
|
points: first?.points,
|
||||||
|
...(extra.sourceId !== undefined ? { sourceId: extra.sourceId || null } : {}),
|
||||||
|
...(extra.chainMode ? { chainMode: extra.chainMode } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3346,9 +3420,85 @@ export function automationStepToConfig(step: AutomationStep): AutomationRuleConf
|
|||||||
notifyTarget: step.notifyTarget,
|
notifyTarget: step.notifyTarget,
|
||||||
activityType: step.activityType,
|
activityType: step.activityType,
|
||||||
points: step.points,
|
points: step.points,
|
||||||
|
assignMode: step.assignMode,
|
||||||
|
assigneeTeamId: step.assigneeTeamId,
|
||||||
|
assigneeUserIds: step.assigneeUserIds,
|
||||||
|
dueOffsetMinutes: step.dueOffsetMinutes,
|
||||||
|
activityTypeDefId: step.activityTypeDefId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* گامهای عملی که باید اجرا شوند.
|
||||||
|
* stop: اولین شرط ناموفق بقیهٔ زنجیره را قطع میکند.
|
||||||
|
* branches: هر دنبالهٔ پیاپی از شرطها (AND) فقط عملهای بعد از خودش را تا شرط بعدی کنترل میکند.
|
||||||
|
*/
|
||||||
|
export function planAutomationActions(
|
||||||
|
steps: AutomationStep[],
|
||||||
|
chainMode: AutomationChainMode | undefined,
|
||||||
|
evalCondition: (step: AutomationStep) => boolean,
|
||||||
|
): AutomationStep[] {
|
||||||
|
const out: AutomationStep[] = [];
|
||||||
|
if (chainMode !== "branches") {
|
||||||
|
for (const step of steps) {
|
||||||
|
if (isAutomationCondition(step)) {
|
||||||
|
if (!evalCondition(step)) break;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (step.action) out.push(step);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
let gateOpen = true;
|
||||||
|
let prevWasCondition = false;
|
||||||
|
for (const step of steps) {
|
||||||
|
if (isAutomationCondition(step)) {
|
||||||
|
const ok = evalCondition(step);
|
||||||
|
gateOpen = prevWasCondition ? gateOpen && ok : ok;
|
||||||
|
prevWasCondition = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
prevWasCondition = false;
|
||||||
|
if (gateOpen && step.action) out.push(step);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** مقدار پاسخ فرم را به گزینهها میشکند (چندانتخابی با کاما ذخیره میشود) */
|
||||||
|
export function splitFormAnswer(raw: unknown): string[] {
|
||||||
|
if (raw == null) return [];
|
||||||
|
return String(raw)
|
||||||
|
.split(/[,،]/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchFormFieldCondition(
|
||||||
|
op: AutomationConditionOp | undefined,
|
||||||
|
raw: unknown,
|
||||||
|
expected: unknown,
|
||||||
|
): boolean {
|
||||||
|
const parts = splitFormAnswer(raw);
|
||||||
|
const value = String(expected ?? "").trim();
|
||||||
|
const whole = raw == null ? "" : String(raw).trim();
|
||||||
|
switch (op ?? "contains") {
|
||||||
|
case "is_set":
|
||||||
|
return parts.length > 0;
|
||||||
|
case "is_empty":
|
||||||
|
return parts.length === 0;
|
||||||
|
case "contains":
|
||||||
|
return Boolean(value) && parts.includes(value);
|
||||||
|
case "not_contains":
|
||||||
|
return !value || !parts.includes(value);
|
||||||
|
case "eq":
|
||||||
|
return whole === value || (parts.length === 1 && parts[0] === value);
|
||||||
|
case "neq":
|
||||||
|
return !(whole === value || (parts.length === 1 && parts[0] === value));
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// —— تهحساب / حسابداری ——
|
// —— تهحساب / حسابداری ——
|
||||||
|
|
||||||
export const TAHSAB_SANAD_TYPES = [
|
export const TAHSAB_SANAD_TYPES = [
|
||||||
|
|||||||
Reference in New Issue
Block a user