feat(automations): form-driven branching rules, round-robin assignment, lead-linked activities
This commit is contained in:
@@ -11,8 +11,12 @@ import {
|
||||
MAX_AUTOMATION_STEPS,
|
||||
isAutomationCondition,
|
||||
type AutomationAction,
|
||||
type AutomationChainMode,
|
||||
type AutomationStep,
|
||||
type AutomationTrigger,
|
||||
type LeadFormDto,
|
||||
type OrgUserDto,
|
||||
type TeamDto,
|
||||
} from '@mento/shared';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
|
||||
@@ -20,11 +24,44 @@ export type AutomationAddPayload =
|
||||
| { kind: 'action'; action: AutomationAction }
|
||||
| { kind: 'condition' };
|
||||
|
||||
const props = defineProps<{
|
||||
trigger: AutomationTrigger;
|
||||
steps: AutomationStep[];
|
||||
selected: 'trigger' | string;
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
trigger: AutomationTrigger;
|
||||
steps: AutomationStep[];
|
||||
selected: 'trigger' | string;
|
||||
chainMode?: AutomationChainMode;
|
||||
form?: LeadFormDto | null;
|
||||
users?: OrgUserDto[];
|
||||
teams?: TeamDto[];
|
||||
}>(),
|
||||
{ chainMode: 'stop', form: null, users: () => [], teams: () => [] },
|
||||
);
|
||||
|
||||
/** شرطی که یک شاخهٔ جدید را شروع میکند (گام قبلی شرط نیست) */
|
||||
function isBranchStart(index: number) {
|
||||
if (props.chainMode !== 'branches') return false;
|
||||
const step = props.steps[index];
|
||||
if (!step || !isAutomationCondition(step)) return false;
|
||||
const prev = props.steps[index - 1];
|
||||
return !prev || !isAutomationCondition(prev);
|
||||
}
|
||||
|
||||
function userName(id?: string | null) {
|
||||
return props.users.find((u) => u.id === id)?.fullName ?? 'کاربر';
|
||||
}
|
||||
|
||||
function assignHint(step: AutomationStep) {
|
||||
const mode = step.assignMode ?? (step.assigneeId ? 'user' : 'owner');
|
||||
if (mode === 'user') return userName(step.assigneeId);
|
||||
if (mode === 'team_round_robin') {
|
||||
const t = props.teams.find((x) => x.id === step.assigneeTeamId);
|
||||
return `نوبتی: ${t?.name ?? 'تیم'}`;
|
||||
}
|
||||
if (mode === 'users_round_robin') {
|
||||
return `نوبتی بین ${toFaDigits((step.assigneeUserIds ?? []).length)} نفر`;
|
||||
}
|
||||
return 'مسئول پرونده';
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [id: 'trigger' | string];
|
||||
@@ -55,6 +92,10 @@ function canRemove(step: AutomationStep) {
|
||||
function stepTitle(step: AutomationStep) {
|
||||
if (isAutomationCondition(step)) {
|
||||
const field = step.conditionField ?? 'has_person';
|
||||
if (field === 'form_field') {
|
||||
const f = props.form?.fields.find((x) => x.key === step.conditionKey);
|
||||
return f?.label ?? AUTOMATION_CONDITION_FIELD_LABELS[field];
|
||||
}
|
||||
return AUTOMATION_CONDITION_FIELD_LABELS[field];
|
||||
}
|
||||
return AUTOMATION_ACTION_LABELS[step.action ?? 'create_activity'];
|
||||
@@ -64,6 +105,10 @@ function stepHint(step: AutomationStep) {
|
||||
if (isAutomationCondition(step)) {
|
||||
const op = step.conditionOp ?? 'is_set';
|
||||
const opLabel = AUTOMATION_CONDITION_OP_LABELS[op];
|
||||
if (step.conditionField === 'form_field') {
|
||||
if (op === 'is_set' || op === 'is_empty') return opLabel;
|
||||
return `${opLabel} «${step.conditionValue ?? ''}»`;
|
||||
}
|
||||
if (step.conditionField === 'deal_amount' && step.conditionValue != null) {
|
||||
return `${opLabel} ${toFaDigits(Number(step.conditionValue))}`;
|
||||
}
|
||||
@@ -90,7 +135,7 @@ function stepHint(step: AutomationStep) {
|
||||
task: 'وظیفه',
|
||||
note: 'یادداشت',
|
||||
};
|
||||
return types[step.activityType ?? 'task'] ?? 'وظیفه';
|
||||
return `${types[step.activityType ?? 'task'] ?? 'وظیفه'} — ${assignHint(step)}`;
|
||||
}
|
||||
|
||||
function openAdd(index: number) {
|
||||
@@ -148,10 +193,11 @@ function nodeClass(step: AutomationStep) {
|
||||
</div>
|
||||
|
||||
<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 }]"
|
||||
:class="[nodeClass(step), { on: selected === step.id, 'branch-start': isBranchStart(i) }]"
|
||||
@click="emit('select', step.id)"
|
||||
>
|
||||
<span class="icon">
|
||||
@@ -301,6 +347,25 @@ function nodeClass(step: AutomationStep) {
|
||||
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:
|
||||
|
||||
@@ -2,17 +2,24 @@
|
||||
import { computed, watch } from 'vue';
|
||||
import type {
|
||||
ActivityType,
|
||||
ActivityTypeDefDto,
|
||||
AutomationAction,
|
||||
AutomationAssignMode,
|
||||
AutomationChainMode,
|
||||
AutomationConditionField,
|
||||
AutomationConditionOp,
|
||||
AutomationStep,
|
||||
AutomationTrigger,
|
||||
LeadFormDto,
|
||||
OrgUserDto,
|
||||
TeamDto,
|
||||
TicketPriority,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
AUTOMATION_ACTION_LABELS,
|
||||
AUTOMATION_ACTIONS,
|
||||
AUTOMATION_ASSIGN_MODE_LABELS,
|
||||
AUTOMATION_CHAIN_MODE_LABELS,
|
||||
AUTOMATION_CONDITION_FIELD_LABELS,
|
||||
AUTOMATION_CONDITION_OP_LABELS,
|
||||
AUTOMATION_NOTIFY_CHANNEL_LABELS,
|
||||
@@ -20,16 +27,22 @@ import {
|
||||
AUTOMATION_TRIGGER_GROUPS,
|
||||
AUTOMATION_TRIGGER_LABELS,
|
||||
conditionFieldsForTrigger,
|
||||
conditionOpsForField,
|
||||
isAutomationCondition,
|
||||
} from '@mento/shared';
|
||||
|
||||
const props = defineProps<{
|
||||
selected: 'trigger' | string;
|
||||
users: OrgUserDto[];
|
||||
forms: LeadFormDto[];
|
||||
teams: TeamDto[];
|
||||
activityTypeDefs: ActivityTypeDefDto[];
|
||||
}>();
|
||||
|
||||
const trigger = defineModel<AutomationTrigger>('trigger', { required: true });
|
||||
const steps = defineModel<AutomationStep[]>('steps', { required: true });
|
||||
const sourceId = defineModel<string | null>('sourceId', { default: null });
|
||||
const chainMode = defineModel<AutomationChainMode>('chainMode', { default: 'branches' });
|
||||
|
||||
const selectedStep = computed(() => {
|
||||
if (props.selected === 'trigger') return null;
|
||||
@@ -42,13 +55,67 @@ const isCondition = computed(
|
||||
|
||||
const conditionFields = computed(() => conditionFieldsForTrigger(trigger.value));
|
||||
|
||||
const conditionOps = computed((): AutomationConditionOp[] => {
|
||||
const field = selectedStep.value?.conditionField;
|
||||
if (field === 'deal_amount') return ['gte', 'lte', 'eq', 'neq'];
|
||||
if (field === 'ticket_priority') return ['eq', 'neq'];
|
||||
return ['is_set', 'is_empty'];
|
||||
const conditionOps = computed((): AutomationConditionOp[] =>
|
||||
conditionOpsForField(selectedStep.value?.conditionField),
|
||||
);
|
||||
|
||||
const selectedForm = computed(() =>
|
||||
sourceId.value ? props.forms.find((f) => f.id === sourceId.value) ?? null : null,
|
||||
);
|
||||
|
||||
const formFields = computed(() =>
|
||||
(selectedForm.value?.fields ?? []).filter((f) => f.fieldType !== 'heading'),
|
||||
);
|
||||
|
||||
const conditionFormField = computed(() => {
|
||||
const key = selectedStep.value?.conditionKey;
|
||||
return key ? formFields.value.find((f) => f.key === key) ?? null : null;
|
||||
});
|
||||
|
||||
const needsConditionValue = computed(() => {
|
||||
const op = selectedStep.value?.conditionOp;
|
||||
return op !== 'is_set' && op !== 'is_empty';
|
||||
});
|
||||
|
||||
const assignModes = Object.keys(AUTOMATION_ASSIGN_MODE_LABELS) as AutomationAssignMode[];
|
||||
|
||||
const dueUnits = [
|
||||
{ value: 'now', label: 'همان لحظه (امروز)' },
|
||||
{ value: 'hours', label: 'چند ساعت بعد' },
|
||||
{ value: 'days', label: 'چند روز بعد' },
|
||||
] as const;
|
||||
type DueUnit = (typeof dueUnits)[number]['value'];
|
||||
|
||||
function dueUnitOf(step: AutomationStep): DueUnit {
|
||||
const m = step.dueOffsetMinutes ?? 0;
|
||||
if (!m) return 'now';
|
||||
return m % 1440 === 0 ? 'days' : 'hours';
|
||||
}
|
||||
|
||||
function dueAmountOf(step: AutomationStep): number {
|
||||
const m = step.dueOffsetMinutes ?? 0;
|
||||
if (!m) return 0;
|
||||
return m % 1440 === 0 ? m / 1440 : Math.round(m / 60);
|
||||
}
|
||||
|
||||
function setDue(step: AutomationStep, unit: DueUnit, amount: number) {
|
||||
const n = Math.max(1, Math.trunc(Number(amount) || 1));
|
||||
step.dueOffsetMinutes = unit === 'now' ? 0 : unit === 'days' ? n * 1440 : n * 60;
|
||||
}
|
||||
|
||||
function toggleUser(step: AutomationStep, id: string, on: boolean) {
|
||||
const cur = new Set(step.assigneeUserIds ?? []);
|
||||
if (on) cur.add(id);
|
||||
else cur.delete(id);
|
||||
step.assigneeUserIds = [...cur];
|
||||
}
|
||||
|
||||
function setConditionKey(step: AutomationStep, key: string) {
|
||||
step.conditionKey = key || null;
|
||||
const field = formFields.value.find((f) => f.key === key);
|
||||
step.conditionValue = field?.options?.[0] ?? '';
|
||||
}
|
||||
|
||||
const activityTypes: Array<{ value: ActivityType; label: string }> = [
|
||||
{ value: 'task', label: 'وظیفه' },
|
||||
{ value: 'call', label: 'تماس' },
|
||||
@@ -68,17 +135,22 @@ function setAction(step: AutomationStep, action: AutomationAction) {
|
||||
step.action = action;
|
||||
if (action === 'add_loyalty_points' && !step.points) step.points = 100;
|
||||
if (action === 'create_activity' && !step.activityType) step.activityType = 'task';
|
||||
if (action === 'create_activity' && !step.assignMode) step.assignMode = 'owner';
|
||||
if (action === 'create_notification' && !step.notifyChannel) step.notifyChannel = 'app';
|
||||
}
|
||||
|
||||
function setConditionField(step: AutomationStep, field: AutomationConditionField) {
|
||||
step.conditionField = field;
|
||||
step.conditionKey = null;
|
||||
if (field === 'deal_amount') {
|
||||
step.conditionOp = 'gte';
|
||||
step.conditionValue = typeof step.conditionValue === 'number' ? step.conditionValue : 0;
|
||||
} else if (field === 'ticket_priority') {
|
||||
step.conditionOp = 'eq';
|
||||
step.conditionValue = 'normal';
|
||||
} else if (field === 'form_field') {
|
||||
step.conditionOp = 'contains';
|
||||
step.conditionValue = '';
|
||||
} else {
|
||||
step.conditionOp = 'is_set';
|
||||
step.conditionValue = undefined;
|
||||
@@ -115,12 +187,38 @@ watch(
|
||||
</optgroup>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="trigger === 'lead_submitted'">
|
||||
فرم
|
||||
<select v-model="sourceId">
|
||||
<option :value="null">همه فرمها</option>
|
||||
<option v-for="f in forms" :key="f.id" :value="f.id">{{ f.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<p v-if="trigger === 'lead_submitted' && !sourceId" class="hint">
|
||||
برای شرط روی پاسخهای فرم، یک فرم مشخص را انتخاب کنید.
|
||||
</p>
|
||||
<label>
|
||||
رفتار شرطها
|
||||
<select v-model="chainMode">
|
||||
<option value="branches">{{ AUTOMATION_CHAIN_MODE_LABELS.branches }}</option>
|
||||
<option value="stop">{{ AUTOMATION_CHAIN_MODE_LABELS.stop }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<p v-if="chainMode === 'branches'" class="hint">
|
||||
هر شرط یک شاخه را شروع میکند. شرطهای پشتسرهم با «و» ترکیب میشوند و فقط
|
||||
عملهای بعد از خودشان (تا شرط بعدی) را کنترل میکنند؛ مثلاً اگر دو گزینه تیک بخورد، هر دو
|
||||
شاخه اجرا میشود.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="selectedStep && isCondition">
|
||||
<h2>گره شرط</h2>
|
||||
<p class="hint">
|
||||
اگر شرط برقرار نباشد، عملهای بعد از این گره اجرا نمیشوند.
|
||||
{{
|
||||
chainMode === 'branches'
|
||||
? 'اگر شرط برقرار نباشد، فقط عملهای این شاخه (تا شرط بعدی) اجرا نمیشوند.'
|
||||
: 'اگر شرط برقرار نباشد، عملهای بعد از این گره اجرا نمیشوند.'
|
||||
}}
|
||||
</p>
|
||||
<label>
|
||||
فیلد
|
||||
@@ -138,6 +236,21 @@ watch(
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<template v-if="selectedStep.conditionField === 'form_field'">
|
||||
<p v-if="!selectedForm" class="hint warn">
|
||||
ابتدا در گره محرک یک فرم مشخص انتخاب کنید.
|
||||
</p>
|
||||
<label v-else>
|
||||
فیلد فرم
|
||||
<select
|
||||
:value="selectedStep.conditionKey ?? ''"
|
||||
@change="setConditionKey(selectedStep, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="" disabled>انتخاب فیلد…</option>
|
||||
<option v-for="f in formFields" :key="f.key" :value="f.key">{{ f.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
<label>
|
||||
مقایسه
|
||||
<select v-model="selectedStep.conditionOp">
|
||||
@@ -158,6 +271,18 @@ watch(
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<template v-if="selectedStep.conditionField === 'form_field' && needsConditionValue">
|
||||
<label v-if="conditionFormField?.options?.length">
|
||||
گزینه
|
||||
<select v-model="selectedStep.conditionValue">
|
||||
<option v-for="o in conditionFormField.options" :key="o" :value="o">{{ o }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-else>
|
||||
مقدار
|
||||
<input v-model.trim="selectedStep.conditionValue" type="text" maxlength="200" />
|
||||
</label>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else-if="selectedStep">
|
||||
@@ -207,15 +332,106 @@ watch(
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="selectedStep.action === 'create_activity'">
|
||||
مسئول فعالیت
|
||||
<select v-model="selectedStep.assigneeId">
|
||||
<option :value="null">مسئول معامله / تیکت</option>
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">
|
||||
<template v-if="selectedStep.action === 'create_activity'">
|
||||
<label v-if="activityTypeDefs.length">
|
||||
نوع سفارشی (اختیاری)
|
||||
<select v-model="selectedStep.activityTypeDefId">
|
||||
<option :value="null">— بدون نوع سفارشی —</option>
|
||||
<option v-for="t in activityTypeDefs" :key="t.id" :value="t.id">{{ t.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
تخصیص به
|
||||
<select v-model="selectedStep.assignMode">
|
||||
<option v-for="m in assignModes" :key="m" :value="m">
|
||||
{{ AUTOMATION_ASSIGN_MODE_LABELS[m] }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="selectedStep.assignMode === 'user'">
|
||||
کاربر
|
||||
<select v-model="selectedStep.assigneeId">
|
||||
<option :value="null" disabled>انتخاب کاربر…</option>
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<template v-else-if="selectedStep.assignMode === 'team_round_robin'">
|
||||
<label>
|
||||
تیم
|
||||
<select v-model="selectedStep.assigneeTeamId">
|
||||
<option :value="null" disabled>انتخاب تیم…</option>
|
||||
<option v-for="t in teams" :key="t.id" :value="t.id">
|
||||
{{ t.name }} ({{ t.memberCount }} نفر)
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<p v-if="!teams.length" class="hint warn">
|
||||
هنوز تیمی تعریف نشده؛ از «تنظیمات ← تیمها و سطح دسترسی» بسازید.
|
||||
</p>
|
||||
</template>
|
||||
<div v-else-if="selectedStep.assignMode === 'users_round_robin'" class="user-picks">
|
||||
<p class="field-label">کاربران</p>
|
||||
<label v-for="u in users" :key="u.id" class="ch">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="(selectedStep.assigneeUserIds ?? []).includes(u.id)"
|
||||
@change="toggleUser(selectedStep, u.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
{{ u.fullName }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</label>
|
||||
</div>
|
||||
<p
|
||||
v-if="selectedStep.assignMode === 'team_round_robin' || selectedStep.assignMode === 'users_round_robin'"
|
||||
class="hint"
|
||||
>
|
||||
هر بار نفر بعدی به نوبت انتخاب میشود تا کارها مساوی تقسیم شوند.
|
||||
</p>
|
||||
|
||||
<div class="due">
|
||||
<label>
|
||||
موعد
|
||||
<select
|
||||
:value="dueUnitOf(selectedStep)"
|
||||
@change="
|
||||
setDue(
|
||||
selectedStep,
|
||||
($event.target as HTMLSelectElement).value as DueUnit,
|
||||
dueAmountOf(selectedStep) || 1,
|
||||
)
|
||||
"
|
||||
>
|
||||
<option v-for="u in dueUnits" :key="u.value" :value="u.value">{{ u.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="dueUnitOf(selectedStep) !== 'now'">
|
||||
تعداد
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
:value="dueAmountOf(selectedStep)"
|
||||
@input="
|
||||
setDue(
|
||||
selectedStep,
|
||||
dueUnitOf(selectedStep),
|
||||
Number(($event.target as HTMLInputElement).value),
|
||||
)
|
||||
"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="selectedStep.action === 'create_activity' || selectedStep.action === 'create_notification'" class="hint">
|
||||
در عنوان و متن میتوانید از
|
||||
<code v-pre>{{title}}</code>، <code v-pre>{{mobile}}</code>، <code v-pre>{{form}}</code> و
|
||||
<code v-pre>{{field.کلید}}</code> استفاده کنید.
|
||||
<template v-if="formFields.length">
|
||||
کلیدها:
|
||||
<span v-for="(f, i) in formFields" :key="f.key">
|
||||
<code>{{ f.key }}</code> ({{ f.label }})<template v-if="i < formFields.length - 1">، </template>
|
||||
</span>
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<template v-if="selectedStep.action === 'create_notification'">
|
||||
<p class="field-label">نحوه ارسال</p>
|
||||
@@ -341,4 +557,35 @@ textarea {
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hint.warn {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.hint code {
|
||||
font-size: 0.76rem;
|
||||
background: #f1f5f9;
|
||||
border-radius: 4px;
|
||||
padding: 0 0.25rem;
|
||||
direction: ltr;
|
||||
unicode-bidi: embed;
|
||||
}
|
||||
|
||||
.user-picks {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
max-height: 12rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.due {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.5rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.due input {
|
||||
width: 5.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user