diff --git a/apps/api/src/automations/automations.service.ts b/apps/api/src/automations/automations.service.ts index 1fc3209..69d0df8 100644 --- a/apps/api/src/automations/automations.service.ts +++ b/apps/api/src/automations/automations.service.ts @@ -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, diff --git a/apps/api/test/automation-branches.test.ts b/apps/api/test/automation-branches.test.ts index fc90d35..3369d50 100644 --- a/apps/api/test/automation-branches.test.ts +++ b/apps/api/test/automation-branches.test.ts @@ -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)); diff --git a/apps/web/src/components/automations/AutomationCanvas.vue b/apps/web/src/components/automations/AutomationCanvas.vue index e5a4087..1055790 100644 --- a/apps/web/src/components/automations/AutomationCanvas.vue +++ b/apps/web/src/components/automations/AutomationCanvas.vue @@ -1,6 +1,7 @@ @@ -179,81 +249,122 @@ function nodeClass(step: AutomationStep) { - - -
- -
-
-

گره بعدی را انتخاب کنید

+
+

+ {{ + addAt.allow === 'condition' + ? 'شرط جدید با «و» به شرط‌های این شاخه اضافه می‌شود' + : 'گره بعدی را انتخاب کنید' + }} +

- - +
@@ -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; - } } diff --git a/apps/web/src/components/automations/AutomationInspector.vue b/apps/web/src/components/automations/AutomationInspector.vue index dda884f..938a8f0 100644 --- a/apps/web/src/components/automations/AutomationInspector.vue +++ b/apps/web/src/components/automations/AutomationInspector.vue @@ -37,6 +37,7 @@ const props = defineProps<{ forms: LeadFormDto[]; teams: TeamDto[]; activityTypeDefs: ActivityTypeDefDto[]; + advanced: boolean; }>(); const trigger = defineModel('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(

برای شرط روی پاسخ‌های فرم، یک فرم مشخص را انتخاب کنید.

-