feat(automations): parallel branch canvas, simple/advanced level per org, rules list UX

This commit is contained in:
2026-10-11 14:48:12 +03:30
parent b3b92abae9
commit 17638786a8
18 changed files with 957 additions and 354 deletions
@@ -6,6 +6,7 @@ import {
} from '@nestjs/common';
import type {
AutomationAction,
AutomationChainMode,
AutomationConditionField,
AutomationConditionOp,
AutomationNotifyChannel,
@@ -19,6 +20,7 @@ import type {
import {
MAX_AUTOMATION_STEPS,
automationStepToConfig,
automationUsesAdvanced,
firstAutomationAction,
matchFormFieldCondition,
normalizeAutomationSteps,
@@ -95,6 +97,7 @@ export class AutomationsService {
const action = firstAutomationAction(steps);
if (!action) throw new BadRequestException('حداقل یک عمل لازم است');
await this.assertStepRefs(organizationId, steps);
await this.assertLevelAllows(organizationId, steps, chainExtra(dto.config).chainMode);
const row = await this.prisma.automationRule.create({
data: {
organizationId,
@@ -130,7 +133,14 @@ export class AutomationsService {
if (steps && !nextAction) {
throw new BadRequestException('حداقل یک عمل لازم است');
}
if (steps) await this.assertStepRefs(organizationId, steps);
if (steps) {
await this.assertStepRefs(organizationId, steps);
await this.assertLevelAllows(
organizationId,
steps,
chainExtra(dto.config ?? asConfig(existing.config)).chainMode,
);
}
const row = await this.prisma.automationRule.update({
where: { id },
@@ -487,6 +497,23 @@ export class AutomationsService {
}
}
private async assertLevelAllows(
organizationId: string,
steps: AutomationStep[],
chainMode: AutomationChainMode | undefined,
) {
if (!automationUsesAdvanced(steps, chainMode)) return;
const org = await this.prisma.organization.findUnique({
where: { id: organizationId },
select: { enabledModules: true },
});
if (!org?.enabledModules.includes('workflow_automations_advanced')) {
throw new BadRequestException(
'شاخه‌ها، شرط روی پاسخ فرم و تقسیم نوبتی فقط در اتوماسیون حرفه‌ای فعال است',
);
}
}
private async executeNotify(
title: string,
config: AutomationRuleConfig,
+29
View File
@@ -1,7 +1,9 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
automationUsesAdvanced,
matchFormFieldCondition,
splitAutomationBranches,
planAutomationActions,
splitFormAnswer,
type AutomationStep,
@@ -91,6 +93,33 @@ describe('اتوماسیون شاخه‌ای فرم', () => {
);
});
it('تفکیک شاخه‌ها برای نمایش موازی', () => {
assert.deepEqual(splitAutomationBranches(BSJ), {
prefixEnd: 0,
branches: [
{ start: 0, end: 2 },
{ start: 2, end: 4 },
{ start: 4, end: 6 },
],
});
const mixed = [act('a0'), cond('c1', 'x'), cond('c2', 'y'), act('a1')];
assert.deepEqual(splitAutomationBranches(mixed), {
prefixEnd: 1,
branches: [{ start: 1, end: 4 }],
});
assert.deepEqual(splitAutomationBranches([act('a0')]), { prefixEnd: 1, branches: [] });
});
it('تشخیص نیاز به اتوماسیون حرفه‌ای', () => {
assert.equal(automationUsesAdvanced([act('a0')], 'stop'), false);
assert.equal(automationUsesAdvanced([act('a0')], 'branches'), true);
assert.equal(automationUsesAdvanced(BSJ, 'stop'), true);
assert.equal(
automationUsesAdvanced([{ ...act('a0'), assignMode: 'team_round_robin' }], undefined),
true,
);
});
it('تقسیم نوبتی مساوی و پایدار', () => {
const team = ['u3', 'u1', 'u2'];
const picks = [1, 2, 3, 4, 5, 6].map((n) => pickRoundRobin(team, n));
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import AutomationNode from '@/components/automations/AutomationNode.vue';
import {
AUTOMATION_ACTION_LABELS,
AUTOMATION_ACTIONS,
@@ -10,6 +11,7 @@ import {
AUTOMATION_TRIGGER_LABELS,
MAX_AUTOMATION_STEPS,
isAutomationCondition,
splitAutomationBranches,
type AutomationAction,
type AutomationChainMode,
type AutomationStep,
@@ -24,6 +26,12 @@ export type AutomationAddPayload =
| { kind: 'action'; action: AutomationAction }
| { kind: 'condition' };
type AddAllow = 'both' | 'action' | 'condition';
type LaneItem =
| { kind: 'wire'; key: string; index: number; allow: AddAllow; end?: boolean }
| { kind: 'node'; key: string; index: number };
type BranchLane = { key: string; start: number; end: number; items: LaneItem[] };
const props = withDefaults(
defineProps<{
trigger: AutomationTrigger;
@@ -37,13 +45,92 @@ const props = withDefaults(
{ chainMode: 'stop', form: null, users: () => [], teams: () => [] },
);
/** شرطی که یک شاخهٔ جدید را شروع می‌کند (گام قبلی شرط نیست) */
function isBranchStart(index: number) {
if (props.chainMode !== 'branches') return false;
const emit = defineEmits<{
select: [id: 'trigger' | string];
add: [payload: AutomationAddPayload, index: number];
remove: [id: string];
removeRange: [start: number, end: number];
addBranch: [];
}>();
const addAt = ref<{ key: string; index: number; allow: AddAllow } | null>(null);
const canAdd = computed(() => props.steps.length < MAX_AUTOMATION_STEPS);
const actionCount = computed(
() => props.steps.filter((s) => !isAutomationCondition(s)).length,
);
const layout = computed((): { main: LaneItem[]; branches: BranchLane[] } => {
const steps = props.steps;
const { prefixEnd, branches } = splitAutomationBranches(steps);
if (props.chainMode !== 'branches' || !branches.length) {
const main: LaneItem[] = [];
steps.forEach((s, i) => {
main.push({ kind: 'wire', key: `w-${s.id}`, index: i, allow: 'both' });
main.push({ kind: 'node', key: s.id, index: i });
});
main.push({ kind: 'wire', key: 'w-end', index: steps.length, allow: 'both', end: true });
return { main, branches: [] };
}
const main: LaneItem[] = [];
for (let i = 0; i < prefixEnd; i++) {
main.push({ kind: 'wire', key: `w-${steps[i].id}`, index: i, allow: 'action' });
main.push({ kind: 'node', key: steps[i].id, index: i });
}
const lanes = branches.map((b): BranchLane => {
const items: LaneItem[] = [];
for (let j = b.start; j < b.end; j++) {
const allow: AddAllow =
j === b.start || isAutomationCondition(steps[j])
? 'condition'
: isAutomationCondition(steps[j - 1])
? 'both'
: 'action';
items.push({ kind: 'wire', key: `w-${steps[j].id}`, index: j, allow });
items.push({ kind: 'node', key: steps[j].id, index: j });
}
const last = steps[b.end - 1];
items.push({
kind: 'wire',
key: `w-end-${steps[b.start].id}`,
index: b.end,
allow: isAutomationCondition(last) ? 'both' : 'action',
end: true,
});
return { key: steps[b.start].id, start: b.start, end: b.end, items };
});
return { main, branches: lanes };
});
const forked = computed(() => layout.value.branches.length > 0);
const canAddBranch = computed(() => {
if (props.steps.length + 2 > MAX_AUTOMATION_STEPS) return false;
const last = props.steps[props.steps.length - 1];
return !last || !isAutomationCondition(last);
});
function branchActionCount(lane: BranchLane) {
return props.steps
.slice(lane.start, lane.end)
.filter((s) => !isAutomationCondition(s)).length;
}
function canRemoveBranch(lane: BranchLane) {
return actionCount.value - branchActionCount(lane) >= 1;
}
function canRemove(index: number) {
const step = props.steps[index];
if (!step || !isAutomationCondition(step)) return false;
const prev = props.steps[index - 1];
return !prev || !isAutomationCondition(prev);
if (!step || props.steps.length <= 1) return false;
if (!isAutomationCondition(step)) return actionCount.value > 1;
if (!forked.value) return true;
const lane = layout.value.branches.find((b) => index >= b.start && index < b.end);
if (!lane) return true;
const conds = props.steps
.slice(lane.start, lane.end)
.filter((s) => isAutomationCondition(s)).length;
return conds > 1;
}
function userName(id?: string | null) {
@@ -63,32 +150,12 @@ function assignHint(step: AutomationStep) {
return 'مسئول پرونده';
}
const emit = defineEmits<{
select: [id: 'trigger' | string];
add: [payload: AutomationAddPayload, index: number];
remove: [id: string];
}>();
const addAt = ref<number | null>(null);
const canAdd = computed(() => props.steps.length < MAX_AUTOMATION_STEPS);
const actionCount = computed(
() => props.steps.filter((s) => !isAutomationCondition(s)).length,
);
type NodeIcon = 'sparkle' | 'flag' | 'bell' | 'gift' | 'filter';
function actionIcon(action: AutomationAction): NodeIcon {
function actionIcon(action: AutomationAction) {
if (action === 'create_notification') return 'bell';
if (action === 'add_loyalty_points') return 'gift';
return 'flag';
}
function canRemove(step: AutomationStep) {
if (props.steps.length <= 1) return false;
if (isAutomationCondition(step)) return true;
return actionCount.value > 1;
}
function stepTitle(step: AutomationStep) {
if (isAutomationCondition(step)) {
const field = step.conditionField ?? 'has_person';
@@ -138,26 +205,29 @@ function stepHint(step: AutomationStep) {
return `${types[step.activityType ?? 'task'] ?? 'وظیفه'} — ${assignHint(step)}`;
}
function openAdd(index: number) {
function openAdd(item: { key: string; index: number; allow: AddAllow }) {
if (!canAdd.value) return;
addAt.value = addAt.value === index ? null : index;
addAt.value =
addAt.value?.key === item.key
? null
: { key: item.key, index: item.index, allow: item.allow };
}
function pickAction(action: AutomationAction) {
if (addAt.value == null) return;
emit('add', { kind: 'action', action }, addAt.value);
if (!addAt.value) return;
emit('add', { kind: 'action', action }, addAt.value.index);
addAt.value = null;
}
function pickCondition() {
if (addAt.value == null) return;
emit('add', { kind: 'condition' }, addAt.value);
if (!addAt.value) return;
emit('add', { kind: 'condition' }, addAt.value.index);
addAt.value = null;
}
function nodeClass(step: AutomationStep) {
if (isAutomationCondition(step)) return 'condition';
return step.action ?? 'create_activity';
function removeBranch(lane: BranchLane) {
addAt.value = null;
emit('removeRange', lane.start, lane.end);
}
</script>
@@ -179,81 +249,122 @@ function nodeClass(step: AutomationStep) {
</span>
</button>
<template v-for="(step, i) in steps" :key="step.id">
<div class="wire" :class="{ open: addAt === i }">
<template v-for="item in layout.main" :key="item.key">
<div
v-if="item.kind === 'wire'"
class="wire"
:class="{ open: addAt?.key === item.key, end: item.end }"
>
<button
v-if="canAdd"
class="plus"
:class="{ big: item.end }"
type="button"
title="افزودن گره"
@click.stop="openAdd(i)"
@click.stop="openAdd(item)"
>
<UiIcon name="plus" :size="14" />
<UiIcon name="plus" :size="item.end ? 16 : 14" />
</button>
</div>
<AutomationNode
v-else
:step="steps[item.index]"
:title="stepTitle(steps[item.index])"
:hint="stepHint(steps[item.index])"
:selected="selected === steps[item.index].id"
:removable="canRemove(item.index)"
@select="emit('select', steps[item.index].id)"
@remove="emit('remove', steps[item.index].id)"
/>
</template>
<div class="node-wrap">
<span v-if="isBranchStart(i)" class="branch-tag">شاخه</span>
<button
class="node action"
type="button"
:class="[nodeClass(step), { on: selected === step.id, 'branch-start': isBranchStart(i) }]"
@click="emit('select', step.id)"
>
<span class="icon">
<UiIcon
:name="isAutomationCondition(step) ? 'filter' : actionIcon(step.action ?? 'create_activity')"
:size="18"
<template v-if="forked">
<div class="wire stub" />
<div class="branches">
<div v-for="(lane, bi) in layout.branches" :key="lane.key" class="branch">
<div class="branch-tag">
<span>شاخه {{ toFaDigits(bi + 1) }}</span>
<button
v-if="canRemoveBranch(lane)"
type="button"
title="حذف این شاخه"
@click.stop="removeBranch(lane)"
>
<UiIcon name="trash" :size="12" />
</button>
</div>
<template v-for="item in lane.items" :key="item.key">
<div
v-if="item.kind === 'wire'"
class="wire"
:class="{ open: addAt?.key === item.key, end: item.end }"
>
<button
v-if="canAdd"
class="plus"
type="button"
:title="item.allow === 'condition' ? 'افزودن شرط («و»)' : 'افزودن گره'"
@click.stop="openAdd(item)"
>
<UiIcon name="plus" :size="14" />
</button>
</div>
<AutomationNode
v-else
:step="steps[item.index]"
:title="stepTitle(steps[item.index])"
:hint="stepHint(steps[item.index])"
:selected="selected === steps[item.index].id"
:removable="canRemove(item.index)"
@select="emit('select', steps[item.index].id)"
@remove="emit('remove', steps[item.index].id)"
/>
</span>
<span class="copy">
<span class="kind">{{ isAutomationCondition(step) ? 'شرط' : 'آنگاه' }}</span>
<strong>{{ stepTitle(step) }}</strong>
<em>{{ stepHint(step) }}</em>
</span>
</button>
</template>
</div>
<button
v-if="canRemove(step)"
class="trash"
class="add-branch"
type="button"
title="حذف"
@click.stop="emit('remove', step.id)"
:disabled="!canAddBranch"
:title="canAddBranch ? '' : 'ابتدا برای شاخهٔ آخر یک عمل بگذارید'"
@click.stop="emit('addBranch')"
>
<UiIcon name="trash" :size="14" />
<UiIcon name="plus" :size="14" />
شاخهٔ جدید
</button>
</div>
</template>
<div class="wire end" :class="{ open: addAt === steps.length }">
<button
v-if="canAdd"
class="plus big"
type="button"
title="افزودن گره بعدی"
@click.stop="openAdd(steps.length)"
>
<UiIcon name="plus" :size="16" />
</button>
</div>
</div>
<div v-if="addAt !== null" class="picker" @click.stop>
<p>گره بعدی را انتخاب کنید</p>
<div v-if="addAt" class="picker" @click.stop>
<p>
{{
addAt.allow === 'condition'
? 'شرط جدید با «و» به شرط‌های این شاخه اضافه می‌شود'
: 'گره بعدی را انتخاب کنید'
}}
</p>
<div class="picks">
<button type="button" class="condition" @click="pickCondition">
<button
v-if="addAt.allow !== 'action'"
type="button"
class="condition"
@click="pickCondition"
>
<UiIcon name="filter" :size="16" />
شرط
</button>
<button
v-for="action in AUTOMATION_ACTIONS"
:key="action"
type="button"
:class="action"
@click="pickAction(action)"
>
<UiIcon :name="actionIcon(action)" :size="16" />
{{ AUTOMATION_ACTION_LABELS[action] }}
</button>
<template v-if="addAt.allow !== 'condition'">
<button
v-for="action in AUTOMATION_ACTIONS"
:key="action"
type="button"
:class="action"
@click="pickAction(action)"
>
<UiIcon :name="actionIcon(action)" :size="16" />
{{ AUTOMATION_ACTION_LABELS[action] }}
</button>
</template>
</div>
<button class="cancel" type="button" @click="addAt = null">انصراف</button>
</div>
@@ -282,95 +393,26 @@ function nodeClass(step: AutomationStep) {
display: flex;
flex-direction: row;
align-items: center;
gap: 0;
min-width: max-content;
min-height: 16rem;
}
.node-wrap {
position: relative;
display: flex;
align-items: center;
}
.node {
width: 13.6rem;
display: flex;
align-items: center;
gap: 0.7rem;
text-align: start;
padding: 0.72rem 0.8rem;
border-radius: var(--radius);
border: 1px solid #d5ddd8;
background: #fff;
box-shadow: 0 10px 24px rgba(23, 33, 28, 0.06);
cursor: pointer;
color: inherit;
font: inherit;
}
.node.trigger {
width: 14.2rem;
min-height: 4.35rem;
display: flex;
align-items: center;
gap: 0.7rem;
text-align: start;
border-radius: 999px;
padding-inline: 1rem;
padding: 0.72rem 1rem;
border: 1px solid #fdba74;
background: #fff7ed;
border-color: #fdba74;
}
.node.action:not(.condition) {
border-radius: var(--radius);
border-inline-start-width: 5px;
}
.node.create_activity {
border-inline-start-color: #14b8a6;
}
.node.create_notification {
border-inline-start-color: #2563eb;
}
.node.add_loyalty_points {
border-inline-start-color: #7c3aed;
}
.node.condition {
width: 15.1rem;
min-height: 4.6rem;
padding-inline: 1.15rem;
border: 0;
border-radius: 0;
background: #fffbeb;
box-shadow: none;
clip-path: polygon(12% 0%, 88% 0%, 100% 50%, 88% 100%, 12% 100%, 0% 50%);
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 {
border-color: #14b8a6;
box-shadow:
0 0 0 3px rgba(20, 184, 166, 0.18),
0 12px 24px rgba(15, 23, 42, 0.08);
box-shadow: 0 10px 24px rgba(23, 33, 28, 0.06);
cursor: pointer;
color: inherit;
font: inherit;
flex-shrink: 0;
}
.node.trigger.on {
@@ -380,46 +422,17 @@ function nodeClass(step: AutomationStep) {
0 12px 24px rgba(15, 23, 42, 0.08);
}
.node.condition.on {
box-shadow: none;
filter:
drop-shadow(0 0 0 3px rgba(217, 119, 6, 0.35))
drop-shadow(0 8px 14px rgba(180, 83, 9, 0.14));
}
.icon {
width: 2.35rem;
height: 2.35rem;
border-radius: var(--radius);
border-radius: 999px;
display: grid;
place-items: center;
flex-shrink: 0;
color: #0f766e;
background: #ecfdf8;
}
.node.trigger .icon {
border-radius: 999px;
color: #c2410c;
background: #ffedd5;
}
.node.create_notification .icon {
color: #1d4ed8;
background: #eff6ff;
}
.node.add_loyalty_points .icon {
color: #7c3aed;
background: #f5f3ff;
}
.node.condition .icon {
border-radius: var(--radius);
color: #b45309;
background: #ffedd5;
}
.copy {
display: grid;
gap: 0.08rem;
@@ -438,39 +451,6 @@ strong {
color: #0f172a;
}
em {
font-style: normal;
font-size: 0.72rem;
color: #64748b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trash {
position: absolute;
top: -0.45rem;
inset-inline-end: -0.45rem;
width: 1.55rem;
height: 1.55rem;
border-radius: 999px;
border: 1px solid #fecaca;
background: #fff;
color: #b91c1c;
display: grid;
place-items: center;
cursor: pointer;
opacity: 0;
pointer-events: none;
}
.node-wrap:hover .trash,
.node-wrap:focus-within .trash,
.node.on ~ .trash {
opacity: 1;
pointer-events: auto;
}
.wire {
position: relative;
width: 3.4rem;
@@ -490,6 +470,14 @@ em {
border-inline-end: 0;
}
.wire.stub {
width: 1.6rem;
}
.wire.stub::after {
display: none;
}
.plus {
position: absolute;
top: 50%;
@@ -522,17 +510,101 @@ em {
.wire.end {
width: 3.8rem;
background: repeating-linear-gradient(
90deg,
#cbd5e1 0 6px,
transparent 6px 10px
);
background: repeating-linear-gradient(90deg, #cbd5e1 0 6px, transparent 6px 10px);
}
.wire.end::after {
display: none;
}
/* انشعاب: یک خط عمودی که از وسط اولین شاخه تا وسط آخرین شاخه کشیده می‌شود */
.flow:has(.branches) {
padding-bottom: 2.6rem;
}
.branches {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.branch {
position: relative;
display: flex;
align-items: center;
padding-block: 1.3rem;
}
.branch::before {
content: '';
position: absolute;
inset-inline-start: 0;
width: 2px;
top: 0;
bottom: 0;
background: #cbd5e1;
}
.branch:first-of-type::before {
top: 50%;
}
.branch:last-of-type::before {
bottom: 50%;
}
.branch-tag {
position: absolute;
top: 0.05rem;
inset-inline-start: 0.9rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
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;
}
.branch-tag button {
display: grid;
place-items: center;
border: 0;
background: none;
padding: 0;
color: #b91c1c;
cursor: pointer;
}
.add-branch {
position: absolute;
top: 100%;
inset-inline-start: 0.9rem;
display: inline-flex;
white-space: nowrap;
align-items: center;
gap: 0.35rem;
border: 1px dashed #f59e0b;
background: #fffbeb;
color: #b45309;
border-radius: 999px;
padding: 0.35rem 0.8rem;
font: inherit;
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
}
.add-branch:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.picker {
margin-top: 1rem;
background: #fff;
@@ -603,16 +675,8 @@ em {
}
@media (max-width: 700px) {
.node {
width: 12.4rem;
}
.node.trigger {
width: 13rem;
}
.node.condition {
width: 14rem;
}
}
</style>
@@ -37,6 +37,7 @@ const props = defineProps<{
forms: LeadFormDto[];
teams: TeamDto[];
activityTypeDefs: ActivityTypeDefDto[];
advanced: boolean;
}>();
const trigger = defineModel<AutomationTrigger>('trigger', { required: true });
@@ -53,7 +54,12 @@ const isCondition = computed(
() => !!selectedStep.value && isAutomationCondition(selectedStep.value),
);
const conditionFields = computed(() => conditionFieldsForTrigger(trigger.value));
const conditionFields = computed(() =>
conditionFieldsForTrigger(trigger.value).filter(
(f) =>
props.advanced || f !== 'form_field' || selectedStep.value?.conditionField === f,
),
);
const conditionOps = computed((): AutomationConditionOp[] =>
conditionOpsForField(selectedStep.value?.conditionField),
@@ -77,7 +83,14 @@ const needsConditionValue = computed(() => {
return op !== 'is_set' && op !== 'is_empty';
});
const assignModes = Object.keys(AUTOMATION_ASSIGN_MODE_LABELS) as AutomationAssignMode[];
const assignModes = computed(() =>
(Object.keys(AUTOMATION_ASSIGN_MODE_LABELS) as AutomationAssignMode[]).filter(
(m) =>
props.advanced ||
(m !== 'team_round_robin' && m !== 'users_round_robin') ||
selectedStep.value?.assignMode === m,
),
);
const dueUnits = [
{ value: 'now', label: 'همان لحظه (امروز)' },
@@ -197,7 +210,7 @@ watch(
<p v-if="trigger === 'lead_submitted' && !sourceId" class="hint">
برای شرط روی پاسخ‌های فرم، یک فرم مشخص را انتخاب کنید.
</p>
<label>
<label v-if="advanced || chainMode === 'branches'">
رفتار شرط‌ها
<select v-model="chainMode">
<option value="branches">{{ AUTOMATION_CHAIN_MODE_LABELS.branches }}</option>
@@ -205,9 +218,13 @@ watch(
</select>
</label>
<p v-if="chainMode === 'branches'" class="hint">
هر شرط یک شاخه را شروع می‌کند. شرط‌های پشت‌سرهم با «و» ترکیب می‌شوند و فقط
عمل‌های بعد از خودشان (تا شرط بعدی) را کنترل می‌کنند؛ مثلاً اگر دو گزینه تیک بخورد، هر دو
شاخه اجرا می‌شود.
شاخه‌ها روی بوم موازی نمایش داده می‌شوند و هر کدام جدا بررسی می‌شود. شرط‌های یک شاخه با «و»
ترکیب می‌شوند؛ مثلاً اگر دو گزینه تیک بخورد، هر دو شاخه اجرا می‌شود. برای شاخهٔ تازه از
دکمهٔ «شاخهٔ جدید» زیر بوم استفاده کنید.
</p>
<p v-else-if="!advanced" class="hint">
اتوماسیون ساده: شرط‌ها پشت‌سرهم بررسی می‌شوند و اولین شرط ناموفق بقیهٔ زنجیره را متوقف
می‌کند. اتوماسیون چندشاخه در نسخهٔ حرفه‌ای فعال است.
</p>
</template>
@@ -0,0 +1,205 @@
<script setup lang="ts">
import { computed } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import { isAutomationCondition, type AutomationStep } from '@mento/shared';
const props = defineProps<{
step: AutomationStep;
title: string;
hint: string;
selected: boolean;
removable: boolean;
}>();
const emit = defineEmits<{ select: []; remove: [] }>();
const isCondition = computed(() => isAutomationCondition(props.step));
const icon = computed(() => {
if (isCondition.value) return 'filter';
if (props.step.action === 'create_notification') return 'bell';
if (props.step.action === 'add_loyalty_points') return 'gift';
return 'flag';
});
const kindClass = computed(() =>
isCondition.value ? 'condition' : props.step.action ?? 'create_activity',
);
</script>
<template>
<div class="node-wrap">
<button
class="node"
type="button"
:class="[kindClass, { on: selected }]"
@click="emit('select')"
>
<span class="icon">
<UiIcon :name="icon" :size="18" />
</span>
<span class="copy">
<span class="kind">{{ isCondition ? 'شرط' : 'آنگاه' }}</span>
<strong>{{ title }}</strong>
<em>{{ hint }}</em>
</span>
</button>
<button
v-if="removable"
class="trash"
type="button"
title="حذف"
@click.stop="emit('remove')"
>
<UiIcon name="trash" :size="14" />
</button>
</div>
</template>
<style scoped>
.node-wrap {
position: relative;
display: flex;
align-items: center;
}
.node {
width: 13.6rem;
display: flex;
align-items: center;
gap: 0.7rem;
text-align: start;
padding: 0.72rem 0.8rem;
border-radius: var(--radius);
border: 1px solid #d5ddd8;
border-inline-start-width: 5px;
background: #fff;
box-shadow: 0 10px 24px rgba(23, 33, 28, 0.06);
cursor: pointer;
color: inherit;
font: inherit;
}
.node.create_activity {
border-inline-start-color: #14b8a6;
}
.node.create_notification {
border-inline-start-color: #2563eb;
}
.node.add_loyalty_points {
border-inline-start-color: #7c3aed;
}
.node.condition {
width: 15.1rem;
min-height: 4.6rem;
padding-inline: 1.15rem;
border: 0;
border-radius: 0;
background: #fffbeb;
box-shadow: none;
clip-path: polygon(12% 0%, 88% 0%, 100% 50%, 88% 100%, 12% 100%, 0% 50%);
filter: drop-shadow(0 8px 14px rgba(180, 83, 9, 0.14));
}
.node.on {
border-color: #14b8a6;
box-shadow:
0 0 0 3px rgba(20, 184, 166, 0.18),
0 12px 24px rgba(15, 23, 42, 0.08);
}
.node.condition.on {
background: #fde68a;
box-shadow: none;
}
.icon {
width: 2.35rem;
height: 2.35rem;
border-radius: var(--radius);
display: grid;
place-items: center;
flex-shrink: 0;
color: #0f766e;
background: #ecfdf8;
}
.node.create_notification .icon {
color: #1d4ed8;
background: #eff6ff;
}
.node.add_loyalty_points .icon {
color: #7c3aed;
background: #f5f3ff;
}
.node.condition .icon {
color: #b45309;
background: #ffedd5;
}
.copy {
display: grid;
gap: 0.08rem;
min-width: 0;
}
.kind {
font-size: 0.7rem;
font-weight: 700;
color: #94a3b8;
}
strong {
font-size: 0.88rem;
font-weight: 600;
color: #0f172a;
}
em {
font-style: normal;
font-size: 0.72rem;
color: #64748b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trash {
position: absolute;
top: -0.45rem;
inset-inline-end: -0.45rem;
width: 1.55rem;
height: 1.55rem;
border-radius: 999px;
border: 1px solid #fecaca;
background: #fff;
color: #b91c1c;
display: grid;
place-items: center;
cursor: pointer;
opacity: 0;
pointer-events: none;
}
.node-wrap:hover .trash,
.node-wrap:focus-within .trash,
.node.on ~ .trash {
opacity: 1;
pointer-events: auto;
}
@media (max-width: 700px) {
.node {
width: 12.4rem;
}
.node.condition {
width: 14rem;
}
}
</style>
+275 -99
View File
@@ -27,15 +27,22 @@ import type {
TeamDto,
} from '@mento/shared';
import {
AUTOMATION_ACTION_LABELS,
AUTOMATION_TRIGGER_LABELS,
MAX_AUTOMATION_STEPS,
automationUsesAdvanced,
conditionFieldsForTrigger,
firstAutomationAction,
isAutomationCondition,
normalizeAutomationSteps,
packAutomationConfig,
splitAutomationBranches,
} from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
const auth = useAuthStore();
const advanced = computed(
() => auth.user?.enabledModules?.includes('workflow_automations_advanced') ?? false,
);
const loading = ref(false);
const saving = ref(false);
@@ -52,7 +59,8 @@ const name = ref('قانون بدون نام');
const trigger = ref<AutomationTrigger>('deal_won');
const isActive = ref(true);
const sourceId = ref<string | null>(null);
const chainMode = ref<AutomationChainMode>('branches');
const chainMode = ref<AutomationChainMode>('stop');
const search = ref('');
const steps = ref<AutomationStep[]>([makeStep('create_activity')]);
const selected = ref<'trigger' | string>('trigger');
const savedSnap = ref('');
@@ -63,9 +71,24 @@ const selectedForm = computed(() =>
? forms.value.find((f) => f.id === sourceId.value) ?? null
: null,
);
const canSave = computed(
() => name.value.trim().length >= 2 && Boolean(firstAutomationAction(steps.value)),
const needsAdvanced = computed(
() => !advanced.value && automationUsesAdvanced(steps.value, chainMode.value),
);
const canSave = computed(
() =>
name.value.trim().length >= 2 &&
Boolean(firstAutomationAction(steps.value)) &&
!needsAdvanced.value,
);
const activeCount = computed(() => items.value.filter((r) => r.isActive).length);
const filteredItems = computed(() => {
const q = search.value.trim();
if (!q) return items.value;
return items.value.filter(
(r) => r.name.includes(q) || (AUTOMATION_TRIGGER_LABELS[r.trigger] ?? '').includes(q),
);
});
function makeStep(action: AutomationAction): AutomationStep {
return {
@@ -94,8 +117,13 @@ function conditionDefaults(field: AutomationConditionField): Partial<AutomationS
return { conditionOp: 'is_set', conditionValue: undefined, conditionKey: null };
}
function allowedConditionFields(t: AutomationTrigger): AutomationConditionField[] {
const all = conditionFieldsForTrigger(t);
return advanced.value ? all : all.filter((f) => f !== 'form_field');
}
function makeCondition(): AutomationStep {
const field = conditionFieldsForTrigger(trigger.value)[0] ?? 'has_person';
const field = allowedConditionFields(trigger.value)[0] ?? 'has_person';
return {
id: `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'condition',
@@ -134,7 +162,7 @@ function hydrate(row?: AutomationRuleDto | null) {
trigger.value = 'deal_won';
isActive.value = true;
sourceId.value = null;
chainMode.value = 'branches';
chainMode.value = advanced.value ? 'branches' : 'stop';
steps.value = [makeStep('create_activity')];
selected.value = 'trigger';
markClean();
@@ -219,10 +247,40 @@ function removeStep(stepId: string) {
}
}
function chainLabel(row: AutomationRuleDto) {
const t = AUTOMATION_TRIGGER_LABELS[row.trigger] ?? row.trigger;
const a = AUTOMATION_ACTION_LABELS[row.action] ?? row.action;
return `${t} ← ${a}`;
function removeRange(start: number, end: number) {
const removed = steps.value.slice(start, end);
const rest = [...steps.value.slice(0, start), ...steps.value.slice(end)];
if (!rest.some((s) => !isAutomationCondition(s))) {
error.value = 'حداقل یک عمل باید در قانون باقی بماند.';
return;
}
if (!confirm('این شاخه و همهٔ گره‌هایش حذف شود؟')) return;
steps.value = rest;
if (removed.some((s) => s.id === selected.value)) selected.value = 'trigger';
}
function addBranch() {
if (steps.value.length + 2 > MAX_AUTOMATION_STEPS) {
error.value = `حداکثر ${toFaDigits(MAX_AUTOMATION_STEPS)} مرحله در هر قانون مجاز است.`;
return;
}
error.value = '';
const cond = makeCondition();
steps.value.push(cond, makeStep('create_activity'));
selected.value = cond.id;
}
function ruleSummary(row: AutomationRuleDto) {
const list = normalizeAutomationSteps(row.action, row.config);
const actions = list.filter((s) => !isAutomationCondition(s)).length;
const conds = list.length - actions;
const parts = [`${toFaDigits(actions)} عمل`];
if (conds) parts.push(`${toFaDigits(conds)} شرط`);
if (row.config.chainMode === 'branches') {
const n = splitAutomationBranches(list).branches.length;
if (n) parts.push(`${toFaDigits(n)} شاخه`);
}
return parts.join(' · ');
}
async function load() {
@@ -323,7 +381,7 @@ onMounted(async () => {
watch(trigger, (next, prev) => {
if (prev === 'lead_submitted' && next !== 'lead_submitted') sourceId.value = null;
const allowed = conditionFieldsForTrigger(next);
const allowed = allowedConditionFields(next);
const fallback = allowed[0] ?? 'has_person';
for (const step of steps.value) {
if (!isAutomationCondition(step)) continue;
@@ -358,37 +416,78 @@ watch(trigger, (next, prev) => {
<aside class="rules">
<div class="rules-head">
<h2>قوانین</h2>
<UiBadge variant="neutral" size="sm">{{ items.length }} قانون</UiBadge>
<span class="counts">
{{ toFaDigits(items.length) }} قانون · {{ toFaDigits(activeCount) }} فعال
</span>
</div>
<UiBadge :variant="advanced ? 'accent' : 'neutral'" size="sm" class="level">
{{ advanced ? 'اتوماسیون حرفه‌ای' : 'اتوماسیون ساده' }}
</UiBadge>
<UiInput
v-if="items.length > 5"
v-model="search"
placeholder="جستجوی قانون…"
class="rules-search"
/>
<ul>
<li v-if="!editingId" class="on draft">
<div class="pick">
<span class="pick-top">
<strong>{{ name.trim() || 'قانون جدید' }}</strong>
<UiBadge variant="warning" size="sm">ذخیره نشده</UiBadge>
</span>
<span class="chain-preview">{{ AUTOMATION_TRIGGER_LABELS[trigger] }}</span>
</div>
</li>
<li
v-for="row in items"
v-for="row in filteredItems"
:key="row.id"
:class="{ on: editingId === row.id }"
:class="{ on: editingId === row.id, off: !row.isActive }"
>
<button class="pick" type="button" @click="openRule(row)">
<div class="pick-top">
<span class="pick-top">
<span class="dot" :class="{ live: row.isActive }" />
<strong>{{ row.name }}</strong>
<UiBadge :variant="row.isActive ? 'success' : 'neutral'" size="sm" dot>
{{ row.isActive ? 'فعال' : 'غیرفعال' }}
<UiBadge v-if="editingId === row.id && dirty" variant="warning" size="sm">
تغییر داده شده
</UiBadge>
</div>
<span class="chain-preview">{{ chainLabel(row) }}</span>
</span>
<span class="chain-preview">
{{ AUTOMATION_TRIGGER_LABELS[row.trigger] ?? row.trigger }}
</span>
<span class="chain-meta">{{ ruleSummary(row) }}</span>
</button>
<div class="mini">
<UiButton variant="ghost" size="sm" @click="toggleActive(row)">
{{ row.isActive ? 'غیرفعال‌سازی' : 'فعال‌سازی' }}
</UiButton>
<UiButton variant="danger" size="sm" @click="removeRule(row.id)">
حذف
</UiButton>
<div class="row-actions">
<button
type="button"
class="icon-btn"
:title="row.isActive ? 'غیرفعال کردن' : 'فعال کردن'"
@click="toggleActive(row)"
>
<span class="switch" :class="{ on: row.isActive }" />
</button>
<button
type="button"
class="icon-btn danger"
title="حذف قانون"
@click="removeRule(row.id)"
>
<UiIcon name="trash" :size="15" />
</button>
</div>
</li>
</ul>
<p v-if="!items.length && !loading" class="muted">هنوز قانونی نیست.</p>
<p v-if="!items.length && !loading" class="muted">
هنوز قانونی نیست؛ از «قانون جدید» شروع کنید.
</p>
<p v-else-if="items.length && !filteredItems.length" class="muted">موردی پیدا نشد.</p>
</aside>
<div class="studio">
<p v-if="needsAdvanced" class="warn-msg">
این قانون از شاخه، شرط روی پاسخ فرم یا تقسیم نوبتی استفاده می‌کند که فقط در اتوماسیون
حرفه‌ای فعال است. برای ذخیره، این موارد را بردارید یا از پشتیبانی ارتقا بخواهید.
</p>
<div class="toolbar">
<div class="name-field">
<span class="name-label">نام قانون:</span>
@@ -421,12 +520,15 @@ watch(trigger, (next, prev) => {
@select="selected = $event"
@add="addStep"
@remove="removeStep"
@remove-range="removeRange"
@add-branch="addBranch"
/>
<AutomationInspector
v-model:trigger="trigger"
v-model:steps="steps"
v-model:source-id="sourceId"
v-model:chain-mode="chainMode"
:advanced="advanced"
:selected="selected"
:users="users"
:forms="forms"
@@ -471,16 +573,18 @@ watch(trigger, (next, prev) => {
}
.rules {
padding: var(--sp-4);
padding: var(--sp-3);
display: grid;
gap: var(--sp-3);
gap: var(--sp-2);
align-content: start;
}
.rules-head {
display: flex;
align-items: center;
align-items: baseline;
justify-content: space-between;
gap: var(--sp-2);
padding-inline: var(--sp-1);
}
.rules-head h2 {
@@ -489,12 +593,23 @@ watch(trigger, (next, prev) => {
font-weight: 700;
}
.counts {
color: var(--color-text-muted);
font-size: var(--fs-xs);
}
.level {
justify-self: start;
}
.rules ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--sp-2);
gap: 0.35rem;
max-height: 60vh;
overflow-y: auto;
}
.rules ul:empty {
@@ -502,12 +617,19 @@ watch(trigger, (next, prev) => {
}
.rules li {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--sp-3);
display: grid;
gap: var(--sp-2);
transition: all var(--transition-fast);
position: relative;
display: flex;
align-items: center;
gap: var(--sp-1);
border: 1px solid transparent;
border-inline-start: 3px solid transparent;
border-radius: var(--radius);
padding: 0.5rem 0.6rem;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.rules li:hover {
background: var(--color-surface-muted, #f8fafc);
}
.rules li.on {
@@ -515,9 +637,20 @@ watch(trigger, (next, prev) => {
background: var(--color-primary-surface);
}
.rules li.draft {
border-style: dashed;
border-inline-start-style: solid;
}
.rules li.off .pick strong {
color: var(--color-text-muted);
}
.pick {
flex: 1;
min-width: 0;
display: grid;
gap: var(--sp-1);
gap: 0.1rem;
text-align: start;
border: 0;
background: none;
@@ -525,33 +658,119 @@ watch(trigger, (next, prev) => {
cursor: pointer;
font: inherit;
color: inherit;
width: 100%;
}
.pick-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
width: 100%;
gap: 0.4rem;
min-width: 0;
}
.pick-top strong {
font-size: var(--fs-sm);
color: var(--color-text-emphasis);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
background: #cbd5e1;
flex-shrink: 0;
}
.dot.live {
background: #16a34a;
}
.chain-preview {
color: var(--color-text-muted);
color: var(--color-text);
font-size: var(--fs-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mini {
.chain-meta {
color: var(--color-text-muted);
font-size: 0.7rem;
}
.row-actions {
display: flex;
gap: var(--sp-2);
justify-content: flex-end;
border-top: 1px dashed var(--color-border);
padding-top: var(--sp-2);
align-items: center;
gap: 0.15rem;
flex-shrink: 0;
}
.icon-btn {
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border: 0;
border-radius: var(--radius);
background: none;
color: var(--color-text-muted);
cursor: pointer;
}
.icon-btn:hover {
background: rgba(15, 23, 42, 0.06);
}
.icon-btn.danger {
opacity: 0;
}
.rules li:hover .icon-btn.danger,
.rules li.on .icon-btn.danger,
.icon-btn.danger:focus-visible {
opacity: 1;
color: var(--color-danger);
}
.switch {
position: relative;
width: 1.9rem;
height: 1.05rem;
border-radius: 999px;
background: #cbd5e1;
transition: background var(--transition-fast);
}
.switch::after {
content: '';
position: absolute;
top: 0.15rem;
inset-inline-start: 0.15rem;
width: 0.75rem;
height: 0.75rem;
border-radius: 999px;
background: #fff;
transition: inset-inline-start var(--transition-fast);
}
.switch.on {
background: #16a34a;
}
.switch.on::after {
inset-inline-start: 1rem;
}
.warn-msg {
margin: 0;
padding: var(--sp-2) var(--sp-3);
border: 1px solid #fcd34d;
border-radius: var(--radius);
background: #fffbeb;
color: #92400e;
font-size: var(--fs-sm);
}
.studio {
@@ -608,67 +827,24 @@ watch(trigger, (next, prev) => {
font-size: var(--fs-sm);
}
@media (min-width: 1280px) {
@media (min-width: 1100px) {
.workspace {
grid-template-columns: minmax(15rem, 20rem) minmax(0, 1fr);
grid-template-areas:
'rules toolbar'
'stage stage';
grid-template-columns: minmax(15rem, 18rem) minmax(0, 1fr);
}
.rules {
grid-area: rules;
/* Match toolbar row height; center content vertically in the card. */
height: 0;
min-height: 100%;
padding: 0.85rem 1rem;
overflow: hidden;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
align-items: stretch;
gap: 0.35rem;
position: sticky;
top: var(--sp-3);
}
.rules .muted {
margin: 0;
}
.studio {
display: contents;
border: 0;
padding: 0;
box-shadow: none;
background: transparent;
}
.toolbar {
grid-area: toolbar;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
background: #fff;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
padding: 0.85rem 1rem;
align-items: end;
}
.stage {
grid-area: stage;
grid-template-columns: minmax(0, 1fr);
.rules ul {
max-height: calc(100vh - 16rem);
}
}
@media (max-width: 1279px) {
@media (max-width: 1099px) {
.rules ul {
display: flex;
overflow-x: auto;
gap: 0.5rem;
}
.rules li {
min-width: 13rem;
flex: 0 0 auto;
max-height: 16rem;
}
}
</style>
@@ -335,6 +335,12 @@ function toggleModule(key: OrgModuleKey) {
}
}
function setAutomationLevel(advanced: boolean) {
const key: OrgModuleKey = 'workflow_automations_advanced';
const rest = moduleDraft.value.filter((k) => k !== key);
moduleDraft.value = advanced ? [...rest, key] : rest;
}
function selectGroup(keys: OrgModuleKey[], on: boolean) {
if (on) {
moduleDraft.value = [...new Set([...moduleDraft.value, ...keys])];
@@ -555,6 +561,29 @@ onMounted(() => {
{{ ORG_MODULE_LABELS[key] }}
</label>
</div>
<div class="mod-group">
<div class="mod-group-head">
<span>سطح اتوماسیون گردش کار</span>
</div>
<label class="mod-item">
<input
type="radio"
:name="`wf-level-${org.id}`"
:checked="!moduleDraft.includes('workflow_automations_advanced')"
@change="setAutomationLevel(false)"
/>
ساده (زنجیرهٔ خطی، مثل قبل)
</label>
<label class="mod-item">
<input
type="radio"
:name="`wf-level-${org.id}`"
:checked="moduleDraft.includes('workflow_automations_advanced')"
@change="setAutomationLevel(true)"
/>
حرفه‌ای (چندشاخه، شرط روی پاسخ فرم، تقسیم نوبتی)
</label>
</div>
</div>
<div class="chat-settings">
Binary file not shown.
Binary file not shown.
Binary file not shown.
+10 -1
View File
@@ -646,7 +646,16 @@
مثال: معامله برنده شد → فعالیت پیگیری ساخته شود، اعلان برود یا امتیاز باشگاه داده شود.
یک محرک مهم: **کلیک روی لینک پیامک**.
#### اتوماسیون بر اساس پاسخ فرم (شاخه‌ای)
#### اتوماسیون ساده و حرفه‌ای
سطح اتوماسیون هر محیط را **سوپرادمین** (پنل مدیریت ← محیط‌ها ← ماژول‌ها ← «سطح اتوماسیون گردش کار») تعیین می‌کند:
- **ساده:** زنجیرهٔ خطی مثل قبل. اولین شرط ناموفق، بقیهٔ زنجیره را متوقف می‌کند.
- **حرفه‌ای:** شاخه‌های موازی، شرط روی پاسخ فرم و تقسیم نوبتی کارها (توضیح در ادامه).
نوع اتوماسیون محیط بالای فهرست قوانین نمایش داده می‌شود. در فهرست قوانین، تعداد کل و تعداد قوانین فعال دیده می‌شود. قانونی که در حال ویرایش است پررنگ است و اگر تغییر ذخیره‌نشده داشته باشد برچسب «تغییر داده شده» می‌گیرد. با کلید کنار هر قانون آن را فعال یا غیرفعال کنید و با آیکون سطل آن را حذف کنید.
#### اتوماسیون بر اساس پاسخ فرم (شاخه‌ای، نسخهٔ حرفه‌ای)
روی بوم، شاخه‌ها **موازی** نمایش داده می‌شوند: از گره «اگر» برای هر شاخه یک فلش جدا کشیده می‌شود. با دکمهٔ **«شاخهٔ جدید»** زیر شاخه‌ها یک شرط و یک عمل تازه اضافه کنید. با آیکون سطل کنار برچسب هر شاخه، کل آن شاخه حذف می‌شود.
محرک **«ثبت لید از فرم»** را انتخاب کنید. در همان پنل محرک می‌توانید:
- **فرم** را مشخص کنید تا قانون فقط برای همان فرم اجرا شود (یا «همه فرم‌ها»).
- **رفتار شرط‌ها** را تعیین کنید:
Binary file not shown.
Binary file not shown.
+2
View File
@@ -364,6 +364,8 @@
- محرک‌ها: برنده شدن معامله، حل تیکت، **کلیک روی لینک پیامک** و …
- اقدام‌ها: ساخت فعالیت، اعلان، امتیاز باشگاه، …
- محرک **ثبت لید از فرم** با فیلتر روی یک فرم مشخص
- دو سطح **ساده / حرفه‌ای** که سوپرادمین برای هر محیط تعیین می‌کند. شاخه‌ها، شرط پاسخ فرم و تقسیم نوبتی مخصوص سطح حرفه‌ای است.
- نمایش **موازی شاخه‌ها** روی بوم (یک فلش از محرک به هر شاخه، افزودن و حذف یک‌جای شاخه)
- شرط **پاسخ فیلد فرم** (شامل / شامل نیست / برابر / پر یا خالی). از چک‌باکس‌های چندانتخابی پشتیبانی می‌کند.
- **حالت شاخه‌ای:** هر شرط فقط اقدام‌های بعد از خودش را کنترل می‌کند، پس یک قانون می‌تواند چند مسیر داشته باشد.
- واگذاری فعالیت: مسئول پرونده، کاربر ثابت، **نوبتی مساوی بین اعضای تیم** یا بین کاربران انتخابی
Binary file not shown.
Binary file not shown.
Binary file not shown.
+47 -2
View File
@@ -114,6 +114,7 @@ export type OrgModuleKey =
| "activities"
| "tickets"
| "workflow_automations"
| "workflow_automations_advanced"
| "message_automations"
| "lead_forms"
| "project_boards"
@@ -153,6 +154,7 @@ export const ORG_MODULE_KEYS: OrgModuleKey[] = [
"activities",
"tickets",
"workflow_automations",
"workflow_automations_advanced",
"message_automations",
"lead_forms",
"leads",
@@ -193,6 +195,7 @@ export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
activities: "فعالیت‌ها",
tickets: "تیکت‌ها",
workflow_automations: "اتوماسیون گردش کار",
workflow_automations_advanced: "اتوماسیون گردش کار حرفه‌ای (چندشاخه)",
message_automations: "اتوماسیون پیامک",
lead_forms: "فرم‌ساز سرنخ",
leads: "مدیریت لیدها",
@@ -283,8 +286,11 @@ export function isOrgModuleKey(value: string): value is OrgModuleKey {
return (ORG_MODULE_KEYS as readonly string[]).includes(value);
}
/** ماژول‌هایی که باید صراحتاً توسط سوپرادمین فعال شوند */
export const OPT_IN_ORG_MODULES: OrgModuleKey[] = ["workflow_automations_advanced"];
export function defaultEnabledModules(): OrgModuleKey[] {
return [...ORG_MODULE_KEYS];
return ORG_MODULE_KEYS.filter((k) => !OPT_IN_ORG_MODULES.includes(k));
}
export interface SubscriptionSummaryDto {
@@ -3105,7 +3111,7 @@ export const AUTOMATION_ACTION_LABELS: Record<AutomationAction, string> = {
add_loyalty_points: "افزودن امتیاز باشگاه",
};
export const MAX_AUTOMATION_STEPS = 12;
export const MAX_AUTOMATION_STEPS = 20;
export const AUTOMATION_ACTIONS = [
"create_activity",
@@ -3464,6 +3470,45 @@ export function planAutomationActions(
return out;
}
export interface AutomationBranchRange {
/** اندیس اولین شرط شاخه */
start: number;
/** اندیس بعد از آخرین گام شاخه */
end: number;
}
/** گام‌های بدون شرط ابتدای زنجیره و بازهٔ هر شاخه (در حالت branches) */
export function splitAutomationBranches(steps: AutomationStep[]): {
prefixEnd: number;
branches: AutomationBranchRange[];
} {
const branches: AutomationBranchRange[] = [];
let prefixEnd = steps.length;
steps.forEach((step, i) => {
if (!isAutomationCondition(step)) return;
const prev = steps[i - 1];
if (prev && isAutomationCondition(prev)) return;
if (!branches.length) prefixEnd = i;
else branches[branches.length - 1].end = i;
branches.push({ start: i, end: steps.length });
});
return { prefixEnd, branches };
}
/** قانون از امکانات اتوماسیون حرفه‌ای (شاخه، شرط فرم، تخصیص نوبتی) استفاده می‌کند */
export function automationUsesAdvanced(
steps: AutomationStep[],
chainMode: AutomationChainMode | undefined,
): boolean {
if (chainMode === "branches") return true;
return steps.some(
(s) =>
(isAutomationCondition(s) && s.conditionField === "form_field") ||
s.assignMode === "team_round_robin" ||
s.assignMode === "users_round_robin",
);
}
/** مقدار پاسخ فرم را به گزینه‌ها می‌شکند (چندانتخابی با کاما ذخیره می‌شود) */
export function splitFormAnswer(raw: unknown): string[] {
if (raw == null) return [];