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'; } from '@nestjs/common';
import type { import type {
AutomationAction, AutomationAction,
AutomationChainMode,
AutomationConditionField, AutomationConditionField,
AutomationConditionOp, AutomationConditionOp,
AutomationNotifyChannel, AutomationNotifyChannel,
@@ -19,6 +20,7 @@ import type {
import { import {
MAX_AUTOMATION_STEPS, MAX_AUTOMATION_STEPS,
automationStepToConfig, automationStepToConfig,
automationUsesAdvanced,
firstAutomationAction, firstAutomationAction,
matchFormFieldCondition, matchFormFieldCondition,
normalizeAutomationSteps, normalizeAutomationSteps,
@@ -95,6 +97,7 @@ export class AutomationsService {
const action = firstAutomationAction(steps); const action = firstAutomationAction(steps);
if (!action) throw new BadRequestException('حداقل یک عمل لازم است'); if (!action) throw new BadRequestException('حداقل یک عمل لازم است');
await this.assertStepRefs(organizationId, steps); await this.assertStepRefs(organizationId, steps);
await this.assertLevelAllows(organizationId, steps, chainExtra(dto.config).chainMode);
const row = await this.prisma.automationRule.create({ const row = await this.prisma.automationRule.create({
data: { data: {
organizationId, organizationId,
@@ -130,7 +133,14 @@ export class AutomationsService {
if (steps && !nextAction) { if (steps && !nextAction) {
throw new BadRequestException('حداقل یک عمل لازم است'); 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({ const row = await this.prisma.automationRule.update({
where: { id }, 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( private async executeNotify(
title: string, title: string,
config: AutomationRuleConfig, config: AutomationRuleConfig,
+29
View File
@@ -1,7 +1,9 @@
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
automationUsesAdvanced,
matchFormFieldCondition, matchFormFieldCondition,
splitAutomationBranches,
planAutomationActions, planAutomationActions,
splitFormAnswer, splitFormAnswer,
type AutomationStep, 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('تقسیم نوبتی مساوی و پایدار', () => { it('تقسیم نوبتی مساوی و پایدار', () => {
const team = ['u3', 'u1', 'u2']; const team = ['u3', 'u1', 'u2'];
const picks = [1, 2, 3, 4, 5, 6].map((n) => pickRoundRobin(team, n)); const picks = [1, 2, 3, 4, 5, 6].map((n) => pickRoundRobin(team, n));
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import UiIcon from '@/components/UiIcon.vue'; import UiIcon from '@/components/UiIcon.vue';
import AutomationNode from '@/components/automations/AutomationNode.vue';
import { import {
AUTOMATION_ACTION_LABELS, AUTOMATION_ACTION_LABELS,
AUTOMATION_ACTIONS, AUTOMATION_ACTIONS,
@@ -10,6 +11,7 @@ import {
AUTOMATION_TRIGGER_LABELS, AUTOMATION_TRIGGER_LABELS,
MAX_AUTOMATION_STEPS, MAX_AUTOMATION_STEPS,
isAutomationCondition, isAutomationCondition,
splitAutomationBranches,
type AutomationAction, type AutomationAction,
type AutomationChainMode, type AutomationChainMode,
type AutomationStep, type AutomationStep,
@@ -24,6 +26,12 @@ export type AutomationAddPayload =
| { kind: 'action'; action: AutomationAction } | { kind: 'action'; action: AutomationAction }
| { kind: 'condition' }; | { 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( const props = withDefaults(
defineProps<{ defineProps<{
trigger: AutomationTrigger; trigger: AutomationTrigger;
@@ -37,13 +45,92 @@ const props = withDefaults(
{ chainMode: 'stop', form: null, users: () => [], teams: () => [] }, { chainMode: 'stop', form: null, users: () => [], teams: () => [] },
); );
/** شرطی که یک شاخهٔ جدید را شروع می‌کند (گام قبلی شرط نیست) */ const emit = defineEmits<{
function isBranchStart(index: number) { select: [id: 'trigger' | string];
if (props.chainMode !== 'branches') return false; 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]; const step = props.steps[index];
if (!step || !isAutomationCondition(step)) return false; if (!step || props.steps.length <= 1) return false;
const prev = props.steps[index - 1]; if (!isAutomationCondition(step)) return actionCount.value > 1;
return !prev || !isAutomationCondition(prev); 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) { function userName(id?: string | null) {
@@ -63,32 +150,12 @@ function assignHint(step: AutomationStep) {
return 'مسئول پرونده'; return 'مسئول پرونده';
} }
const emit = defineEmits<{ function actionIcon(action: AutomationAction) {
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 {
if (action === 'create_notification') return 'bell'; if (action === 'create_notification') return 'bell';
if (action === 'add_loyalty_points') return 'gift'; if (action === 'add_loyalty_points') return 'gift';
return 'flag'; 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) { function stepTitle(step: AutomationStep) {
if (isAutomationCondition(step)) { if (isAutomationCondition(step)) {
const field = step.conditionField ?? 'has_person'; const field = step.conditionField ?? 'has_person';
@@ -138,26 +205,29 @@ function stepHint(step: AutomationStep) {
return `${types[step.activityType ?? 'task'] ?? 'وظیفه'} — ${assignHint(step)}`; return `${types[step.activityType ?? 'task'] ?? 'وظیفه'} — ${assignHint(step)}`;
} }
function openAdd(index: number) { function openAdd(item: { key: string; index: number; allow: AddAllow }) {
if (!canAdd.value) return; 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) { function pickAction(action: AutomationAction) {
if (addAt.value == null) return; if (!addAt.value) return;
emit('add', { kind: 'action', action }, addAt.value); emit('add', { kind: 'action', action }, addAt.value.index);
addAt.value = null; addAt.value = null;
} }
function pickCondition() { function pickCondition() {
if (addAt.value == null) return; if (!addAt.value) return;
emit('add', { kind: 'condition' }, addAt.value); emit('add', { kind: 'condition' }, addAt.value.index);
addAt.value = null; addAt.value = null;
} }
function nodeClass(step: AutomationStep) { function removeBranch(lane: BranchLane) {
if (isAutomationCondition(step)) return 'condition'; addAt.value = null;
return step.action ?? 'create_activity'; emit('removeRange', lane.start, lane.end);
} }
</script> </script>
@@ -179,81 +249,122 @@ function nodeClass(step: AutomationStep) {
</span> </span>
</button> </button>
<template v-for="(step, i) in steps" :key="step.id"> <template v-for="item in layout.main" :key="item.key">
<div class="wire" :class="{ open: addAt === i }"> <div
v-if="item.kind === 'wire'"
class="wire"
:class="{ open: addAt?.key === item.key, end: item.end }"
>
<button <button
v-if="canAdd" v-if="canAdd"
class="plus" class="plus"
:class="{ big: item.end }"
type="button" type="button"
title="افزودن گره" title="افزودن گره"
@click.stop="openAdd(i)" @click.stop="openAdd(item)"
> >
<UiIcon name="plus" :size="14" /> <UiIcon name="plus" :size="item.end ? 16 : 14" />
</button> </button>
</div> </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"> <template v-if="forked">
<span v-if="isBranchStart(i)" class="branch-tag">شاخه</span> <div class="wire stub" />
<button <div class="branches">
class="node action" <div v-for="(lane, bi) in layout.branches" :key="lane.key" class="branch">
type="button" <div class="branch-tag">
:class="[nodeClass(step), { on: selected === step.id, 'branch-start': isBranchStart(i) }]" <span>شاخه {{ toFaDigits(bi + 1) }}</span>
@click="emit('select', step.id)" <button
> v-if="canRemoveBranch(lane)"
<span class="icon"> type="button"
<UiIcon title="حذف این شاخه"
:name="isAutomationCondition(step) ? 'filter' : actionIcon(step.action ?? 'create_activity')" @click.stop="removeBranch(lane)"
:size="18" >
<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> </template>
<span class="copy"> </div>
<span class="kind">{{ isAutomationCondition(step) ? 'شرط' : 'آنگاه' }}</span>
<strong>{{ stepTitle(step) }}</strong>
<em>{{ stepHint(step) }}</em>
</span>
</button>
<button <button
v-if="canRemove(step)" class="add-branch"
class="trash"
type="button" type="button"
title="حذف" :disabled="!canAddBranch"
@click.stop="emit('remove', step.id)" :title="canAddBranch ? '' : 'ابتدا برای شاخهٔ آخر یک عمل بگذارید'"
@click.stop="emit('addBranch')"
> >
<UiIcon name="trash" :size="14" /> <UiIcon name="plus" :size="14" />
شاخهٔ جدید
</button> </button>
</div> </div>
</template> </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>
<div v-if="addAt !== null" class="picker" @click.stop> <div v-if="addAt" class="picker" @click.stop>
<p>گره بعدی را انتخاب کنید</p> <p>
{{
addAt.allow === 'condition'
? 'شرط جدید با «و» به شرط‌های این شاخه اضافه می‌شود'
: 'گره بعدی را انتخاب کنید'
}}
</p>
<div class="picks"> <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" /> <UiIcon name="filter" :size="16" />
شرط شرط
</button> </button>
<button <template v-if="addAt.allow !== 'condition'">
v-for="action in AUTOMATION_ACTIONS" <button
:key="action" v-for="action in AUTOMATION_ACTIONS"
type="button" :key="action"
:class="action" type="button"
@click="pickAction(action)" :class="action"
> @click="pickAction(action)"
<UiIcon :name="actionIcon(action)" :size="16" /> >
{{ AUTOMATION_ACTION_LABELS[action] }} <UiIcon :name="actionIcon(action)" :size="16" />
</button> {{ AUTOMATION_ACTION_LABELS[action] }}
</button>
</template>
</div> </div>
<button class="cancel" type="button" @click="addAt = null">انصراف</button> <button class="cancel" type="button" @click="addAt = null">انصراف</button>
</div> </div>
@@ -282,95 +393,26 @@ function nodeClass(step: AutomationStep) {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 0;
min-width: max-content; min-width: max-content;
min-height: 16rem; 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 { .node.trigger {
width: 14.2rem; width: 14.2rem;
min-height: 4.35rem; min-height: 4.35rem;
display: flex;
align-items: center;
gap: 0.7rem;
text-align: start;
border-radius: 999px; border-radius: 999px;
padding-inline: 1rem; padding: 0.72rem 1rem;
border: 1px solid #fdba74;
background: #fff7ed; background: #fff7ed;
border-color: #fdba74; box-shadow: 0 10px 24px rgba(23, 33, 28, 0.06);
} cursor: pointer;
color: inherit;
.node.action:not(.condition) { font: inherit;
border-radius: var(--radius); flex-shrink: 0;
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);
} }
.node.trigger.on { .node.trigger.on {
@@ -380,46 +422,17 @@ function nodeClass(step: AutomationStep) {
0 12px 24px rgba(15, 23, 42, 0.08); 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 { .icon {
width: 2.35rem; width: 2.35rem;
height: 2.35rem; height: 2.35rem;
border-radius: var(--radius); border-radius: 999px;
display: grid; display: grid;
place-items: center; place-items: center;
flex-shrink: 0; flex-shrink: 0;
color: #0f766e;
background: #ecfdf8;
}
.node.trigger .icon {
border-radius: 999px;
color: #c2410c; color: #c2410c;
background: #ffedd5; 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 { .copy {
display: grid; display: grid;
gap: 0.08rem; gap: 0.08rem;
@@ -438,39 +451,6 @@ strong {
color: #0f172a; 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 { .wire {
position: relative; position: relative;
width: 3.4rem; width: 3.4rem;
@@ -490,6 +470,14 @@ em {
border-inline-end: 0; border-inline-end: 0;
} }
.wire.stub {
width: 1.6rem;
}
.wire.stub::after {
display: none;
}
.plus { .plus {
position: absolute; position: absolute;
top: 50%; top: 50%;
@@ -522,17 +510,101 @@ em {
.wire.end { .wire.end {
width: 3.8rem; width: 3.8rem;
background: repeating-linear-gradient( background: repeating-linear-gradient(90deg, #cbd5e1 0 6px, transparent 6px 10px);
90deg,
#cbd5e1 0 6px,
transparent 6px 10px
);
} }
.wire.end::after { .wire.end::after {
display: none; 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 { .picker {
margin-top: 1rem; margin-top: 1rem;
background: #fff; background: #fff;
@@ -603,16 +675,8 @@ em {
} }
@media (max-width: 700px) { @media (max-width: 700px) {
.node {
width: 12.4rem;
}
.node.trigger { .node.trigger {
width: 13rem; width: 13rem;
} }
.node.condition {
width: 14rem;
}
} }
</style> </style>
@@ -37,6 +37,7 @@ const props = defineProps<{
forms: LeadFormDto[]; forms: LeadFormDto[];
teams: TeamDto[]; teams: TeamDto[];
activityTypeDefs: ActivityTypeDefDto[]; activityTypeDefs: ActivityTypeDefDto[];
advanced: boolean;
}>(); }>();
const trigger = defineModel<AutomationTrigger>('trigger', { required: true }); const trigger = defineModel<AutomationTrigger>('trigger', { required: true });
@@ -53,7 +54,12 @@ const isCondition = computed(
() => !!selectedStep.value && isAutomationCondition(selectedStep.value), () => !!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[] => const conditionOps = computed((): AutomationConditionOp[] =>
conditionOpsForField(selectedStep.value?.conditionField), conditionOpsForField(selectedStep.value?.conditionField),
@@ -77,7 +83,14 @@ const needsConditionValue = computed(() => {
return op !== 'is_set' && op !== 'is_empty'; 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 = [ const dueUnits = [
{ value: 'now', label: 'همان لحظه (امروز)' }, { value: 'now', label: 'همان لحظه (امروز)' },
@@ -197,7 +210,7 @@ watch(
<p v-if="trigger === 'lead_submitted' && !sourceId" class="hint"> <p v-if="trigger === 'lead_submitted' && !sourceId" class="hint">
برای شرط روی پاسخ‌های فرم، یک فرم مشخص را انتخاب کنید. برای شرط روی پاسخ‌های فرم، یک فرم مشخص را انتخاب کنید.
</p> </p>
<label> <label v-if="advanced || chainMode === 'branches'">
رفتار شرط‌ها رفتار شرط‌ها
<select v-model="chainMode"> <select v-model="chainMode">
<option value="branches">{{ AUTOMATION_CHAIN_MODE_LABELS.branches }}</option> <option value="branches">{{ AUTOMATION_CHAIN_MODE_LABELS.branches }}</option>
@@ -205,9 +218,13 @@ watch(
</select> </select>
</label> </label>
<p v-if="chainMode === 'branches'" class="hint"> <p v-if="chainMode === 'branches'" class="hint">
هر شرط یک شاخه را شروع می‌کند. شرط‌های پشت‌سرهم با «و» ترکیب می‌شوند و فقط شاخه‌ها روی بوم موازی نمایش داده می‌شوند و هر کدام جدا بررسی می‌شود. شرط‌های یک شاخه با «و»
عمل‌های بعد از خودشان (تا شرط بعدی) را کنترل می‌کنند؛ مثلاً اگر دو گزینه تیک بخورد، هر دو ترکیب می‌شوند؛ مثلاً اگر دو گزینه تیک بخورد، هر دو شاخه اجرا می‌شود. برای شاخهٔ تازه از
شاخه اجرا می‌شود. دکمهٔ «شاخهٔ جدید» زیر بوم استفاده کنید.
</p>
<p v-else-if="!advanced" class="hint">
اتوماسیون ساده: شرط‌ها پشت‌سرهم بررسی می‌شوند و اولین شرط ناموفق بقیهٔ زنجیره را متوقف
می‌کند. اتوماسیون چندشاخه در نسخهٔ حرفه‌ای فعال است.
</p> </p>
</template> </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, TeamDto,
} from '@mento/shared'; } from '@mento/shared';
import { import {
AUTOMATION_ACTION_LABELS,
AUTOMATION_TRIGGER_LABELS, AUTOMATION_TRIGGER_LABELS,
MAX_AUTOMATION_STEPS, MAX_AUTOMATION_STEPS,
automationUsesAdvanced,
conditionFieldsForTrigger, conditionFieldsForTrigger,
firstAutomationAction, firstAutomationAction,
isAutomationCondition, isAutomationCondition,
normalizeAutomationSteps, normalizeAutomationSteps,
packAutomationConfig, packAutomationConfig,
splitAutomationBranches,
} from '@mento/shared'; } 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 loading = ref(false);
const saving = ref(false); const saving = ref(false);
@@ -52,7 +59,8 @@ 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 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 steps = ref<AutomationStep[]>([makeStep('create_activity')]);
const selected = ref<'trigger' | string>('trigger'); const selected = ref<'trigger' | string>('trigger');
const savedSnap = ref(''); const savedSnap = ref('');
@@ -63,9 +71,24 @@ const selectedForm = computed(() =>
? forms.value.find((f) => f.id === sourceId.value) ?? null ? forms.value.find((f) => f.id === sourceId.value) ?? null
: null, : null,
); );
const canSave = computed( const needsAdvanced = computed(
() => name.value.trim().length >= 2 && Boolean(firstAutomationAction(steps.value)), () => !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 { function makeStep(action: AutomationAction): AutomationStep {
return { return {
@@ -94,8 +117,13 @@ function conditionDefaults(field: AutomationConditionField): Partial<AutomationS
return { conditionOp: 'is_set', conditionValue: undefined, conditionKey: null }; 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 { function makeCondition(): AutomationStep {
const field = conditionFieldsForTrigger(trigger.value)[0] ?? 'has_person'; const field = allowedConditionFields(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',
@@ -134,7 +162,7 @@ function hydrate(row?: AutomationRuleDto | null) {
trigger.value = 'deal_won'; trigger.value = 'deal_won';
isActive.value = true; isActive.value = true;
sourceId.value = null; sourceId.value = null;
chainMode.value = 'branches'; chainMode.value = advanced.value ? 'branches' : 'stop';
steps.value = [makeStep('create_activity')]; steps.value = [makeStep('create_activity')];
selected.value = 'trigger'; selected.value = 'trigger';
markClean(); markClean();
@@ -219,10 +247,40 @@ function removeStep(stepId: string) {
} }
} }
function chainLabel(row: AutomationRuleDto) { function removeRange(start: number, end: number) {
const t = AUTOMATION_TRIGGER_LABELS[row.trigger] ?? row.trigger; const removed = steps.value.slice(start, end);
const a = AUTOMATION_ACTION_LABELS[row.action] ?? row.action; const rest = [...steps.value.slice(0, start), ...steps.value.slice(end)];
return `${t} ← ${a}`; 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() { async function load() {
@@ -323,7 +381,7 @@ onMounted(async () => {
watch(trigger, (next, prev) => { watch(trigger, (next, prev) => {
if (prev === 'lead_submitted' && next !== 'lead_submitted') sourceId.value = null; if (prev === 'lead_submitted' && next !== 'lead_submitted') sourceId.value = null;
const allowed = conditionFieldsForTrigger(next); const allowed = allowedConditionFields(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;
@@ -358,37 +416,78 @@ watch(trigger, (next, prev) => {
<aside class="rules"> <aside class="rules">
<div class="rules-head"> <div class="rules-head">
<h2>قوانین</h2> <h2>قوانین</h2>
<UiBadge variant="neutral" size="sm">{{ items.length }} قانون</UiBadge> <span class="counts">
{{ toFaDigits(items.length) }} قانون · {{ toFaDigits(activeCount) }} فعال
</span>
</div> </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> <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 <li
v-for="row in items" v-for="row in filteredItems"
:key="row.id" :key="row.id"
:class="{ on: editingId === row.id }" :class="{ on: editingId === row.id, off: !row.isActive }"
> >
<button class="pick" type="button" @click="openRule(row)"> <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> <strong>{{ row.name }}</strong>
<UiBadge :variant="row.isActive ? 'success' : 'neutral'" size="sm" dot> <UiBadge v-if="editingId === row.id && dirty" variant="warning" size="sm">
{{ row.isActive ? 'فعال' : 'غیرفعال' }} تغییر داده شده
</UiBadge> </UiBadge>
</div> </span>
<span class="chain-preview">{{ chainLabel(row) }}</span> <span class="chain-preview">
{{ AUTOMATION_TRIGGER_LABELS[row.trigger] ?? row.trigger }}
</span>
<span class="chain-meta">{{ ruleSummary(row) }}</span>
</button> </button>
<div class="mini"> <div class="row-actions">
<UiButton variant="ghost" size="sm" @click="toggleActive(row)"> <button
{{ row.isActive ? 'غیرفعال‌سازی' : 'فعال‌سازی' }} type="button"
</UiButton> class="icon-btn"
<UiButton variant="danger" size="sm" @click="removeRule(row.id)"> :title="row.isActive ? 'غیرفعال کردن' : 'فعال کردن'"
حذف @click="toggleActive(row)"
</UiButton> >
<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> </div>
</li> </li>
</ul> </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> </aside>
<div class="studio"> <div class="studio">
<p v-if="needsAdvanced" class="warn-msg">
این قانون از شاخه، شرط روی پاسخ فرم یا تقسیم نوبتی استفاده می‌کند که فقط در اتوماسیون
حرفه‌ای فعال است. برای ذخیره، این موارد را بردارید یا از پشتیبانی ارتقا بخواهید.
</p>
<div class="toolbar"> <div class="toolbar">
<div class="name-field"> <div class="name-field">
<span class="name-label">نام قانون:</span> <span class="name-label">نام قانون:</span>
@@ -421,12 +520,15 @@ watch(trigger, (next, prev) => {
@select="selected = $event" @select="selected = $event"
@add="addStep" @add="addStep"
@remove="removeStep" @remove="removeStep"
@remove-range="removeRange"
@add-branch="addBranch"
/> />
<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:source-id="sourceId"
v-model:chain-mode="chainMode" v-model:chain-mode="chainMode"
:advanced="advanced"
:selected="selected" :selected="selected"
:users="users" :users="users"
:forms="forms" :forms="forms"
@@ -471,16 +573,18 @@ watch(trigger, (next, prev) => {
} }
.rules { .rules {
padding: var(--sp-4); padding: var(--sp-3);
display: grid; display: grid;
gap: var(--sp-3); gap: var(--sp-2);
align-content: start; align-content: start;
} }
.rules-head { .rules-head {
display: flex; display: flex;
align-items: center; align-items: baseline;
justify-content: space-between; justify-content: space-between;
gap: var(--sp-2);
padding-inline: var(--sp-1);
} }
.rules-head h2 { .rules-head h2 {
@@ -489,12 +593,23 @@ watch(trigger, (next, prev) => {
font-weight: 700; font-weight: 700;
} }
.counts {
color: var(--color-text-muted);
font-size: var(--fs-xs);
}
.level {
justify-self: start;
}
.rules ul { .rules ul {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
display: grid; display: grid;
gap: var(--sp-2); gap: 0.35rem;
max-height: 60vh;
overflow-y: auto;
} }
.rules ul:empty { .rules ul:empty {
@@ -502,12 +617,19 @@ watch(trigger, (next, prev) => {
} }
.rules li { .rules li {
border: 1px solid var(--color-border); position: relative;
border-radius: var(--radius-lg); display: flex;
padding: var(--sp-3); align-items: center;
display: grid; gap: var(--sp-1);
gap: var(--sp-2); border: 1px solid transparent;
transition: all var(--transition-fast); 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 { .rules li.on {
@@ -515,9 +637,20 @@ watch(trigger, (next, prev) => {
background: var(--color-primary-surface); 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 { .pick {
flex: 1;
min-width: 0;
display: grid; display: grid;
gap: var(--sp-1); gap: 0.1rem;
text-align: start; text-align: start;
border: 0; border: 0;
background: none; background: none;
@@ -525,33 +658,119 @@ watch(trigger, (next, prev) => {
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
color: inherit; color: inherit;
width: 100%;
} }
.pick-top { .pick-top {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 0.4rem;
gap: var(--sp-2); min-width: 0;
width: 100%;
} }
.pick-top strong { .pick-top strong {
font-size: var(--fs-sm); font-size: var(--fs-sm);
color: var(--color-text-emphasis); 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 { .chain-preview {
color: var(--color-text-muted); color: var(--color-text);
font-size: var(--fs-xs); 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; display: flex;
gap: var(--sp-2); align-items: center;
justify-content: flex-end; gap: 0.15rem;
border-top: 1px dashed var(--color-border); flex-shrink: 0;
padding-top: var(--sp-2); }
.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 { .studio {
@@ -608,67 +827,24 @@ watch(trigger, (next, prev) => {
font-size: var(--fs-sm); font-size: var(--fs-sm);
} }
@media (min-width: 1280px) { @media (min-width: 1100px) {
.workspace { .workspace {
grid-template-columns: minmax(15rem, 20rem) minmax(0, 1fr); grid-template-columns: minmax(15rem, 18rem) minmax(0, 1fr);
grid-template-areas:
'rules toolbar'
'stage stage';
} }
.rules { .rules {
grid-area: rules; position: sticky;
/* Match toolbar row height; center content vertically in the card. */ top: var(--sp-3);
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;
} }
.rules .muted { .rules ul {
margin: 0; max-height: calc(100vh - 16rem);
}
.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);
} }
} }
@media (max-width: 1279px) { @media (max-width: 1099px) {
.rules ul { .rules ul {
display: flex; max-height: 16rem;
overflow-x: auto;
gap: 0.5rem;
}
.rules li {
min-width: 13rem;
flex: 0 0 auto;
} }
} }
</style> </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) { function selectGroup(keys: OrgModuleKey[], on: boolean) {
if (on) { if (on) {
moduleDraft.value = [...new Set([...moduleDraft.value, ...keys])]; moduleDraft.value = [...new Set([...moduleDraft.value, ...keys])];
@@ -555,6 +561,29 @@ onMounted(() => {
{{ ORG_MODULE_LABELS[key] }} {{ ORG_MODULE_LABELS[key] }}
</label> </label>
</div> </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>
<div class="chat-settings"> <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" | "activities"
| "tickets" | "tickets"
| "workflow_automations" | "workflow_automations"
| "workflow_automations_advanced"
| "message_automations" | "message_automations"
| "lead_forms" | "lead_forms"
| "project_boards" | "project_boards"
@@ -153,6 +154,7 @@ export const ORG_MODULE_KEYS: OrgModuleKey[] = [
"activities", "activities",
"tickets", "tickets",
"workflow_automations", "workflow_automations",
"workflow_automations_advanced",
"message_automations", "message_automations",
"lead_forms", "lead_forms",
"leads", "leads",
@@ -193,6 +195,7 @@ export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
activities: "فعالیت‌ها", activities: "فعالیت‌ها",
tickets: "تیکت‌ها", tickets: "تیکت‌ها",
workflow_automations: "اتوماسیون گردش کار", workflow_automations: "اتوماسیون گردش کار",
workflow_automations_advanced: "اتوماسیون گردش کار حرفه‌ای (چندشاخه)",
message_automations: "اتوماسیون پیامک", message_automations: "اتوماسیون پیامک",
lead_forms: "فرم‌ساز سرنخ", lead_forms: "فرم‌ساز سرنخ",
leads: "مدیریت لیدها", leads: "مدیریت لیدها",
@@ -283,8 +286,11 @@ export function isOrgModuleKey(value: string): value is OrgModuleKey {
return (ORG_MODULE_KEYS as readonly string[]).includes(value); return (ORG_MODULE_KEYS as readonly string[]).includes(value);
} }
/** ماژول‌هایی که باید صراحتاً توسط سوپرادمین فعال شوند */
export const OPT_IN_ORG_MODULES: OrgModuleKey[] = ["workflow_automations_advanced"];
export function defaultEnabledModules(): OrgModuleKey[] { export function defaultEnabledModules(): OrgModuleKey[] {
return [...ORG_MODULE_KEYS]; return ORG_MODULE_KEYS.filter((k) => !OPT_IN_ORG_MODULES.includes(k));
} }
export interface SubscriptionSummaryDto { export interface SubscriptionSummaryDto {
@@ -3105,7 +3111,7 @@ export const AUTOMATION_ACTION_LABELS: Record<AutomationAction, string> = {
add_loyalty_points: "افزودن امتیاز باشگاه", add_loyalty_points: "افزودن امتیاز باشگاه",
}; };
export const MAX_AUTOMATION_STEPS = 12; export const MAX_AUTOMATION_STEPS = 20;
export const AUTOMATION_ACTIONS = [ export const AUTOMATION_ACTIONS = [
"create_activity", "create_activity",
@@ -3464,6 +3470,45 @@ export function planAutomationActions(
return out; 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[] { export function splitFormAnswer(raw: unknown): string[] {
if (raw == null) return []; if (raw == null) return [];