feat(automations): form-driven branching rules, round-robin assignment, lead-linked activities
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user