feat(automations): form-driven branching rules, round-robin assignment, lead-linked activities

This commit is contained in:
2026-10-11 14:12:36 +03:30
parent b30f68563d
commit b3b92abae9
27 changed files with 1160 additions and 91 deletions
+70 -19
View File
@@ -14,12 +14,17 @@ import type { AutomationAddPayload } from '@/components/automations/AutomationCa
import AutomationInspector from '@/components/automations/AutomationInspector.vue';
import { toFaDigits } from '@/lib/jalali';
import type {
ActivityTypeDefDto,
AutomationAction,
AutomationChainMode,
AutomationConditionField,
AutomationRuleDto,
AutomationStep,
AutomationTrigger,
CreateAutomationRuleRequest,
LeadFormDto,
OrgUserDto,
TeamDto,
} from '@mento/shared';
import {
AUTOMATION_ACTION_LABELS,
@@ -38,16 +43,26 @@ const error = ref('');
const ok = ref('');
const items = ref<AutomationRuleDto[]>([]);
const users = ref<OrgUserDto[]>([]);
const forms = ref<LeadFormDto[]>([]);
const teams = ref<TeamDto[]>([]);
const activityTypeDefs = ref<ActivityTypeDefDto[]>([]);
const editingId = ref<string | null>(null);
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 steps = ref<AutomationStep[]>([makeStep('create_activity')]);
const selected = ref<'trigger' | string>('trigger');
const savedSnap = ref('');
const dirty = computed(() => snap() !== savedSnap.value);
const selectedForm = computed(() =>
trigger.value === 'lead_submitted' && sourceId.value
? forms.value.find((f) => f.id === sourceId.value) ?? null
: null,
);
const canSave = computed(
() => name.value.trim().length >= 2 && Boolean(firstAutomationAction(steps.value)),
);
@@ -63,19 +78,29 @@ function makeStep(action: AutomationAction): AutomationStep {
notifyTarget: action === 'create_notification' ? 'staff' : undefined,
assigneeId: null,
notifyUserId: null,
assignMode: action === 'create_activity' ? 'owner' : undefined,
dueOffsetMinutes: action === 'create_activity' ? 0 : undefined,
};
}
function conditionDefaults(field: AutomationConditionField): Partial<AutomationStep> {
if (field === 'deal_amount') return { conditionOp: 'gte', conditionValue: 0, conditionKey: null };
if (field === 'ticket_priority') {
return { conditionOp: 'eq', conditionValue: 'normal', conditionKey: null };
}
if (field === 'form_field') {
return { conditionOp: 'contains', conditionValue: '', conditionKey: null };
}
return { conditionOp: 'is_set', conditionValue: undefined, conditionKey: null };
}
function makeCondition(): AutomationStep {
const field = conditionFieldsForTrigger(trigger.value)[0] ?? 'has_person';
return {
id: `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'condition',
conditionField: field,
conditionOp:
field === 'deal_amount' ? 'gte' : field === 'ticket_priority' ? 'eq' : 'is_set',
conditionValue:
field === 'deal_amount' ? 0 : field === 'ticket_priority' ? 'normal' : undefined,
...conditionDefaults(field),
};
}
@@ -84,6 +109,8 @@ function snap() {
name: name.value.trim(),
trigger: trigger.value,
isActive: isActive.value,
sourceId: sourceId.value,
chainMode: chainMode.value,
steps: steps.value,
});
}
@@ -106,6 +133,8 @@ function hydrate(row?: AutomationRuleDto | null) {
name.value = 'قانون بدون نام';
trigger.value = 'deal_won';
isActive.value = true;
sourceId.value = null;
chainMode.value = 'branches';
steps.value = [makeStep('create_activity')];
selected.value = 'trigger';
markClean();
@@ -115,6 +144,8 @@ function hydrate(row?: AutomationRuleDto | null) {
name.value = row.name;
trigger.value = row.trigger;
isActive.value = row.isActive;
sourceId.value = row.config.sourceId ?? null;
chainMode.value = row.config.chainMode ?? 'stop';
steps.value = normalizeAutomationSteps(row.action, row.config).map((s, i) => ({
...s,
id: s.id || `step-${i}`,
@@ -130,6 +161,14 @@ function hydrate(row?: AutomationRuleDto | null) {
activityType:
s.activityType ?? (s.action === 'create_activity' ? 'task' : undefined),
points: s.points ?? (s.action === 'add_loyalty_points' ? 100 : undefined),
assignMode:
s.assignMode ??
(s.action === 'create_activity' ? (s.assigneeId ? 'user' : 'owner') : undefined),
assigneeTeamId: s.assigneeTeamId ?? null,
assigneeUserIds: s.assigneeUserIds ?? [],
dueOffsetMinutes: s.dueOffsetMinutes ?? null,
activityTypeDefId: s.activityTypeDefId ?? null,
conditionKey: s.conditionKey ?? null,
}));
if (!steps.value.length) steps.value = [makeStep('create_activity')];
selected.value = 'trigger';
@@ -190,12 +229,20 @@ async function load() {
loading.value = true;
error.value = '';
try {
const [rulesRes, usersRes] = await Promise.all([
const [rulesRes, usersRes, formsRes, teamsRes, typesRes] = await Promise.all([
api.get<AutomationRuleDto[]>('/automations'),
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
api.get<LeadFormDto[]>('/lead-forms').catch(() => ({ data: [] as LeadFormDto[] })),
api.get<TeamDto[]>('/teams').catch(() => ({ data: [] as TeamDto[] })),
api
.get<ActivityTypeDefDto[]>('/activities/types')
.catch(() => ({ data: [] as ActivityTypeDefDto[] })),
]);
items.value = rulesRes.data;
users.value = usersRes.data;
forms.value = formsRes.data;
teams.value = teamsRes.data;
activityTypeDefs.value = typesRes.data.filter((t) => t.isActive && !t.isSystem);
if (editingId.value) {
const cur = items.value.find((x) => x.id === editingId.value);
if (cur && !dirty.value) hydrate(cur);
@@ -223,7 +270,12 @@ async function save() {
trigger: trigger.value,
action,
isActive: isActive.value,
config: packAutomationConfig(steps.value),
config: packAutomationConfig(steps.value, {
sourceId: trigger.value === 'lead_submitted' || trigger.value === 'sms_link_clicked'
? sourceId.value
: null,
chainMode: chainMode.value,
}),
};
if (editingId.value) {
await api.patch(`/automations/${editingId.value}`, payload);
@@ -269,25 +321,15 @@ onMounted(async () => {
hydrate(items.value[0] ?? null);
});
watch(trigger, (next) => {
watch(trigger, (next, prev) => {
if (prev === 'lead_submitted' && next !== 'lead_submitted') sourceId.value = null;
const allowed = conditionFieldsForTrigger(next);
const fallback = allowed[0] ?? 'has_person';
for (const step of steps.value) {
if (!isAutomationCondition(step)) continue;
if (step.conditionField && allowed.includes(step.conditionField)) continue;
step.conditionField = fallback;
step.conditionOp =
fallback === 'deal_amount'
? 'gte'
: fallback === 'ticket_priority'
? 'eq'
: 'is_set';
step.conditionValue =
fallback === 'deal_amount'
? 0
: fallback === 'ticket_priority'
? 'normal'
: undefined;
Object.assign(step, conditionDefaults(fallback));
}
});
</script>
@@ -372,6 +414,10 @@ watch(trigger, (next) => {
:trigger="trigger"
:steps="steps"
:selected="selected"
:chain-mode="chainMode"
:form="selectedForm"
:users="users"
:teams="teams"
@select="selected = $event"
@add="addStep"
@remove="removeStep"
@@ -379,8 +425,13 @@ watch(trigger, (next) => {
<AutomationInspector
v-model:trigger="trigger"
v-model:steps="steps"
v-model:source-id="sourceId"
v-model:chain-mode="chainMode"
:selected="selected"
:users="users"
:forms="forms"
:teams="teams"
:activity-type-defs="activityTypeDefs"
/>
</div>
</div>