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
@@ -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>
+24
View File
@@ -391,6 +391,20 @@ onMounted(() => {
{{ activity.createdBy?.fullName || '—' }}
</dd>
</div>
<div v-if="activity.lead">
<dt>
<UiIcon name="target" :size="15" />
لید
</dt>
<dd>
<RouterLink
class="lead-link"
:to="{ name: 'lead-detail', params: { id: activity.lead.id } }"
>
{{ activity.lead.title }}
</RouterLink>
</dd>
</div>
<div>
<dt>
<UiIcon name="calendar" :size="15" />
@@ -684,6 +698,16 @@ dd {
text-align: start;
}
.lead-link {
color: var(--accent, #0f766e);
font-weight: 600;
text-decoration: none;
}
.lead-link:hover {
text-decoration: underline;
}
.person {
display: flex;
align-items: center;
+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>
+60 -1
View File
@@ -17,12 +17,15 @@ import {
} from '@/components/ui';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import type {
ActivityDto,
ConvertLeadResult,
LeadDto,
LeadEventDto,
LeadStageDto,
OrgUserDto,
PaginatedResponse,
} from '@mento/shared';
import { ACTIVITY_TYPE_LABELS } from '@mento/shared';
type LeadDetail = LeadDto & { events: LeadEventDto[] };
@@ -39,6 +42,7 @@ const okMsg = ref('');
const lead = ref<LeadDetail | null>(null);
const stages = ref<LeadStageDto[]>([]);
const users = ref<OrgUserDto[]>([]);
const activities = ref<ActivityDto[]>([]);
const stageId = ref('');
const assigneeId = ref('');
@@ -85,12 +89,18 @@ async function load() {
loading.value = true;
error.value = '';
try {
const [leadRes, stagesRes, usersRes] = await Promise.all([
const [leadRes, stagesRes, usersRes, actRes] = await Promise.all([
api.get<LeadDetail>(`/leads/${id.value}`),
api.get<LeadStageDto[]>('/leads/stages'),
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
api
.get<PaginatedResponse<ActivityDto>>('/activities', {
params: { filter: 'all', leadId: id.value, limit: 30 },
})
.catch(() => null),
]);
lead.value = leadRes.data;
activities.value = actRes?.data.items ?? [];
stages.value = stagesRes.data;
users.value = usersRes.data.filter((u) => u.isActive !== false);
stageId.value = leadRes.data.stageId;
@@ -394,6 +404,22 @@ onMounted(load);
<RelatedTasksPanel :entity="{ type: 'lead', id: lead.id, label: lead.title }" />
<UiCard v-if="activities.length" title="فعالیت‌های مرتبط">
<ul class="lead-acts">
<li v-for="a in activities" :key="a.id">
<RouterLink :to="{ name: 'activity-detail', params: { id: a.id } }">
<strong>{{ a.title }}</strong>
</RouterLink>
<span class="muted">
{{ a.typeDef?.name || ACTIVITY_TYPE_LABELS[a.type] }}
· {{ a.assignee?.fullName || 'بدون مسئول' }}
· {{ formatDate(a.dueAt) }}
· {{ a.status === 'done' ? 'انجام شد' : a.status === 'canceled' ? 'لغو شده' : 'باز' }}
</span>
</li>
</ul>
</UiCard>
<UiCard title="جدول زمانی">
<ul v-if="lead.events.length" class="timeline">
<li v-for="ev in lead.events" :key="ev.id">
@@ -538,6 +564,39 @@ onMounted(load);
margin-bottom: 0;
}
.lead-acts {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.6rem;
}
.lead-acts li {
display: grid;
gap: 0.15rem;
padding-bottom: 0.55rem;
border-bottom: 1px solid #f1f5f9;
}
.lead-acts li:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.lead-acts a {
color: inherit;
text-decoration: none;
}
.lead-acts a:hover strong {
color: var(--accent, #0f766e);
}
.lead-acts .muted {
font-size: 0.78rem;
}
.timeline {
list-style: none;
margin: 0;