Files
CRM_Montzar/apps/web/src/components/automations/AutomationInspector.vue
T

642 lines
21 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
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,
AUTOMATION_NOTIFY_CHANNELS,
AUTOMATION_TRIGGER_GROUPS,
AUTOMATION_TRIGGER_LABELS,
conditionFieldsForTrigger,
conditionOpsForField,
formConditionValues,
isAutomationCondition,
} from '@mento/shared';
const props = defineProps<{
selected: 'trigger' | string;
users: OrgUserDto[];
forms: LeadFormDto[];
teams: TeamDto[];
activityTypeDefs: ActivityTypeDefDto[];
advanced: boolean;
}>();
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;
return steps.value.find((s) => s.id === props.selected) ?? null;
});
const isCondition = computed(
() => !!selectedStep.value && isAutomationCondition(selectedStep.value),
);
const conditionFields = computed(() =>
conditionFieldsForTrigger(trigger.value).filter(
(f) =>
props.advanced || f !== 'form_field' || selectedStep.value?.conditionField === f,
),
);
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 = computed(() =>
(Object.keys(AUTOMATION_ASSIGN_MODE_LABELS) as AutomationAssignMode[]).filter(
(m) =>
props.advanced ||
(m !== 'team_round_robin' && m !== 'users_round_robin') ||
selectedStep.value?.assignMode === m,
),
);
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] ?? '';
step.conditionValues = step.conditionValue ? [step.conditionValue] : [];
}
function toggleConditionValue(step: AutomationStep, option: string, on: boolean) {
const cur = formConditionValues(step).filter((v) => v !== option);
if (on) cur.push(option);
const order = conditionFormField.value?.options ?? [];
cur.sort((a, b) => order.indexOf(a) - order.indexOf(b));
step.conditionValues = cur;
step.conditionValue = cur[0] ?? '';
}
function multiValueHint(op: AutomationConditionOp | undefined) {
if (op === 'contains_any') return 'کافی است حداقل یکی از این گزینه‌ها انتخاب شده باشد.';
if (op === 'not_contains') return 'هیچ‌کدام از این گزینه‌ها نباید انتخاب شده باشد.';
if (op === 'eq') return 'دقیقاً همین گزینه‌ها (نه بیشتر و نه کمتر) باید انتخاب شده باشند.';
if (op === 'neq') return 'برقرار است مگر این‌که دقیقاً همین گزینه‌ها انتخاب شده باشند.';
return 'همهٔ این گزینه‌ها باید با هم انتخاب شده باشند.';
}
const activityTypes: Array<{ value: ActivityType; label: string }> = [
{ value: 'task', label: 'وظیفه' },
{ value: 'call', label: 'تماس' },
{ value: 'meeting', label: 'جلسه' },
{ value: 'note', label: 'یادداشت' },
];
const ticketPriorities: Array<{ value: TicketPriority; label: string }> = [
{ value: 'low', label: 'کم' },
{ value: 'normal', label: 'عادی' },
{ value: 'high', label: 'بالا' },
{ value: 'urgent', label: 'فوری' },
];
function setAction(step: AutomationStep, action: AutomationAction) {
step.kind = 'action';
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;
step.conditionValues = [];
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;
}
}
watch(
selectedStep,
(step) => {
if (!step || isAutomationCondition(step) || step.action !== 'create_notification') return;
if (!step.notifyChannel) step.notifyChannel = 'app';
if (!step.notifyTarget) step.notifyTarget = 'staff';
},
{ immediate: true },
);
</script>
<template>
<aside class="inspector">
<template v-if="selected === 'trigger'">
<h2>گره محرک</h2>
<p class="hint">وقتی این اتفاق بیفتد، زنجیره از راست به چپ اجرا می‌شود.</p>
<label>
اگر
<select v-model="trigger">
<optgroup
v-for="group in AUTOMATION_TRIGGER_GROUPS"
:key="group.label"
:label="group.label"
>
<option v-for="t in group.items" :key="t" :value="t">
{{ AUTOMATION_TRIGGER_LABELS[t] }}
</option>
</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 v-if="advanced || chainMode === 'branches'">
رفتار شرط‌ها
<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>
<p v-else-if="!advanced" class="hint">
اتوماسیون ساده: شرط‌ها پشت‌سرهم بررسی می‌شوند و اولین شرط ناموفق بقیهٔ زنجیره را متوقف
می‌کند. اتوماسیون چندشاخه در نسخهٔ حرفه‌ای فعال است.
</p>
</template>
<template v-else-if="selectedStep && isCondition">
<h2>گره شرط</h2>
<p class="hint">
{{
chainMode === 'branches'
? 'اگر شرط برقرار نباشد، فقط عمل‌های این شاخه (تا شرط بعدی) اجرا نمی‌شوند.'
: 'اگر شرط برقرار نباشد، عمل‌های بعد از این گره اجرا نمی‌شوند.'
}}
</p>
<label>
فیلد
<select
:value="selectedStep.conditionField"
@change="
setConditionField(
selectedStep,
($event.target as HTMLSelectElement).value as AutomationConditionField,
)
"
>
<option v-for="f in conditionFields" :key="f" :value="f">
{{ AUTOMATION_CONDITION_FIELD_LABELS[f] }}
</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">
<option v-for="op in conditionOps" :key="op" :value="op">
{{ AUTOMATION_CONDITION_OP_LABELS[op] }}
</option>
</select>
</label>
<label v-if="selectedStep.conditionField === 'deal_amount'">
مبلغ
<input v-model.number="selectedStep.conditionValue" type="number" min="0" />
</label>
<label v-if="selectedStep.conditionField === 'ticket_priority'">
اولویت
<select v-model="selectedStep.conditionValue">
<option v-for="p in ticketPriorities" :key="p.value" :value="p.value">
{{ p.label }}
</option>
</select>
</label>
<template v-if="selectedStep.conditionField === 'form_field' && needsConditionValue">
<div v-if="conditionFormField?.options?.length" class="user-picks">
<p class="field-label">گزینه‌ها (یک یا چند مورد)</p>
<label v-for="o in conditionFormField.options" :key="o" class="ch">
<input
type="checkbox"
:checked="formConditionValues(selectedStep).includes(o)"
@change="toggleConditionValue(selectedStep, o, ($event.target as HTMLInputElement).checked)"
/>
{{ o }}
</label>
<p v-if="formConditionValues(selectedStep).length > 1" class="hint">
{{ multiValueHint(selectedStep.conditionOp) }}
</p>
</div>
<label v-else>
مقدار
<input v-model.trim="selectedStep.conditionValue" type="text" maxlength="200" />
</label>
</template>
</template>
<template v-else-if="selectedStep">
<h2>گره عمل</h2>
<p class="hint">بعد از محرک (و شرط‌ها)، این کار به ترتیب انجام می‌شود.</p>
<label>
آنگاه
<select
:value="selectedStep.action"
@change="
setAction(
selectedStep,
($event.target as HTMLSelectElement).value as AutomationAction,
)
"
>
<option v-for="a in AUTOMATION_ACTIONS" :key="a" :value="a">
{{ AUTOMATION_ACTION_LABELS[a] }}
</option>
</select>
</label>
<label>
عنوان
<input
v-model="selectedStep.title"
type="text"
maxlength="200"
placeholder="اختیاری"
/>
</label>
<label>
توضیح / متن پیام
<textarea
v-model="selectedStep.body"
rows="3"
maxlength="1000"
placeholder="اختیاری"
/>
</label>
<label v-if="selectedStep.action === 'create_activity'">
نوع فعالیت
<select v-model="selectedStep.activityType">
<option v-for="t in activityTypes" :key="t.value" :value="t.value">
{{ t.label }}
</option>
</select>
</label>
<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 }}
</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>
<div class="channels">
<label
v-for="ch in AUTOMATION_NOTIFY_CHANNELS"
:key="ch"
class="ch"
>
<input
v-model="selectedStep.notifyChannel"
type="radio"
:value="ch"
/>
{{ AUTOMATION_NOTIFY_CHANNEL_LABELS[ch] }}
</label>
</div>
<label>
گیرنده
<select v-model="selectedStep.notifyTarget">
<option value="staff">کاربر تیم</option>
<option value="person">مخاطب معامله / تیکت</option>
</select>
</label>
<label v-if="selectedStep.notifyTarget !== 'person'">
کاربر
<select v-model="selectedStep.notifyUserId">
<option :value="null">مسئول معامله / تیکت</option>
<option v-for="u in users" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</select>
</label>
<p v-if="selectedStep.notifyTarget === 'person'" class="hint">
پیامک به موبایل مخاطب ارسال می‌شود. ناتیفیکیشن داخلی برای مخاطب نیست.
</p>
<p
v-else-if="selectedStep.notifyChannel === 'sms' || selectedStep.notifyChannel === 'both'"
class="hint"
>
پیامک به موبایل همان کاربر ارسال می‌شود.
</p>
</template>
<label v-if="selectedStep.action === 'add_loyalty_points'">
امتیاز
<input v-model.number="selectedStep.points" type="number" min="1" />
</label>
</template>
<p v-else class="hint">یک گره را از بوم انتخاب کنید.</p>
</aside>
</template>
<style scoped>
.inspector {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-content: start;
gap: 0.75rem;
padding: 1rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
min-width: 0;
}
h2 {
margin: 0;
font-size: 0.98rem;
font-weight: 600;
}
.hint {
margin: 0;
color: #64748b;
font-size: 0.82rem;
line-height: 1.6;
}
label,
.field-label {
display: grid;
gap: 0.35rem;
font-size: 0.84rem;
color: #64748b;
min-width: 0;
}
.field-label {
margin: 0;
font-weight: 700;
}
input,
select,
textarea {
border: 1px solid #e2e8f0;
border-radius: var(--radius);
padding: 0.55rem 0.7rem;
font: inherit;
color: #0f172a;
background: #fff;
min-width: 0;
max-width: 100%;
text-overflow: ellipsis;
}
.channels {
display: flex;
flex-wrap: wrap;
gap: 0.55rem 0.85rem;
}
.ch {
display: inline-flex !important;
flex-direction: row;
align-items: center;
gap: 0.32rem;
color: #334155;
cursor: pointer;
white-space: nowrap;
}
.ch input {
accent-color: #14b8a6;
width: 14px;
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>