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
+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">