Files
CRM_Montzar/apps/web/src/views/AutomationsSettingsView.vue
T

852 lines
23 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import {
UiBadge,
UiButton,
UiInput,
UiPageHeader,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import AutomationCanvas from '@/components/automations/AutomationCanvas.vue';
import type { AutomationAddPayload } from '@/components/automations/AutomationCanvas.vue';
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_TRIGGER_LABELS,
MAX_AUTOMATION_STEPS,
automationUsesAdvanced,
conditionFieldsForTrigger,
firstAutomationAction,
isAutomationCondition,
normalizeAutomationSteps,
packAutomationConfig,
splitAutomationBranches,
} from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
const auth = useAuthStore();
const advanced = computed(
() => auth.user?.enabledModules?.includes('workflow_automations_advanced') ?? false,
);
const loading = ref(false);
const saving = ref(false);
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>('stop');
const search = ref('');
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 needsAdvanced = computed(
() => !advanced.value && automationUsesAdvanced(steps.value, chainMode.value),
);
const canSave = computed(
() =>
name.value.trim().length >= 2 &&
Boolean(firstAutomationAction(steps.value)) &&
!needsAdvanced.value,
);
const activeCount = computed(() => items.value.filter((r) => r.isActive).length);
const filteredItems = computed(() => {
const q = search.value.trim();
if (!q) return items.value;
return items.value.filter(
(r) => r.name.includes(q) || (AUTOMATION_TRIGGER_LABELS[r.trigger] ?? '').includes(q),
);
});
function makeStep(action: AutomationAction): AutomationStep {
return {
id: `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'action',
action,
activityType: action === 'create_activity' ? 'task' : undefined,
points: action === 'add_loyalty_points' ? 100 : undefined,
notifyChannel: action === 'create_notification' ? 'app' : undefined,
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: '', conditionValues: [], conditionKey: null };
}
return { conditionOp: 'is_set', conditionValue: undefined, conditionKey: null };
}
function allowedConditionFields(t: AutomationTrigger): AutomationConditionField[] {
const all = conditionFieldsForTrigger(t);
return advanced.value ? all : all.filter((f) => f !== 'form_field');
}
function makeCondition(): AutomationStep {
const field = allowedConditionFields(trigger.value)[0] ?? 'has_person';
return {
id: `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'condition',
conditionField: field,
...conditionDefaults(field),
};
}
function snap() {
return JSON.stringify({
name: name.value.trim(),
trigger: trigger.value,
isActive: isActive.value,
sourceId: sourceId.value,
chainMode: chainMode.value,
steps: steps.value,
});
}
function markClean() {
savedSnap.value = snap();
}
function errText(e: unknown, fallback: string) {
if (axios.isAxiosError(e) && !e.response) return 'اتصال به API برقرار نشد.';
const msg = axios.isAxiosError(e) ? e.response?.data?.message : null;
if (Array.isArray(msg) && msg.length) return msg.join(' ');
if (typeof msg === 'string' && msg.trim()) return msg;
return fallback;
}
function hydrate(row?: AutomationRuleDto | null) {
if (!row) {
editingId.value = null;
name.value = 'قانون بدون نام';
trigger.value = 'deal_won';
isActive.value = true;
sourceId.value = null;
chainMode.value = advanced.value ? 'branches' : 'stop';
steps.value = [makeStep('create_activity')];
selected.value = 'trigger';
markClean();
return;
}
editingId.value = row.id;
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}`,
kind: s.kind === 'condition' ? 'condition' : 'action',
assigneeId: s.assigneeId ?? null,
notifyUserId: s.notifyUserId ?? null,
notifyChannel:
s.notifyChannel ??
(s.action === 'create_notification' ? 'app' : undefined),
notifyTarget:
s.notifyTarget ??
(s.action === 'create_notification' ? 'staff' : undefined),
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,
conditionValues: s.conditionValues ?? [],
}));
if (!steps.value.length) steps.value = [makeStep('create_activity')];
selected.value = 'trigger';
markClean();
}
function confirmLeave() {
if (!dirty.value) return true;
return confirm('تغییرات ذخیره نشده از بین می‌رود. ادامه می‌دهید؟');
}
function startNew() {
if (!confirmLeave()) return;
hydrate(null);
}
function openRule(row: AutomationRuleDto) {
if (editingId.value === row.id) return;
if (!confirmLeave()) return;
hydrate(row);
}
function addStep(payload: AutomationAddPayload, index: number) {
if (steps.value.length >= MAX_AUTOMATION_STEPS) {
error.value = `حداکثر ${toFaDigits(MAX_AUTOMATION_STEPS)} مرحله در هر قانون مجاز است.`;
return;
}
error.value = '';
const step =
payload.kind === 'condition'
? makeCondition()
: makeStep(payload.action);
steps.value.splice(index, 0, step);
selected.value = step.id;
}
function removeStep(stepId: string) {
if (steps.value.length <= 1) {
error.value = 'حداقل یک مرحله باید در زنجیره باقی بماند.';
return;
}
const i = steps.value.findIndex((s) => s.id === stepId);
if (i < 0) return;
steps.value.splice(i, 1);
if (selected.value === stepId) {
const next = steps.value[Math.max(0, i - 1)];
selected.value = next?.id ?? 'trigger';
}
}
function removeRange(start: number, end: number) {
const removed = steps.value.slice(start, end);
const rest = [...steps.value.slice(0, start), ...steps.value.slice(end)];
if (!rest.some((s) => !isAutomationCondition(s))) {
error.value = 'حداقل یک عمل باید در قانون باقی بماند.';
return;
}
if (!confirm('این شاخه و همهٔ گره‌هایش حذف شود؟')) return;
steps.value = rest;
if (removed.some((s) => s.id === selected.value)) selected.value = 'trigger';
}
function addBranch() {
if (steps.value.length + 2 > MAX_AUTOMATION_STEPS) {
error.value = `حداکثر ${toFaDigits(MAX_AUTOMATION_STEPS)} مرحله در هر قانون مجاز است.`;
return;
}
error.value = '';
const cond = makeCondition();
steps.value.push(cond, makeStep('create_activity'));
selected.value = cond.id;
}
function ruleSummary(row: AutomationRuleDto) {
const list = normalizeAutomationSteps(row.action, row.config);
const actions = list.filter((s) => !isAutomationCondition(s)).length;
const conds = list.length - actions;
const parts = [`${toFaDigits(actions)} عمل`];
if (conds) parts.push(`${toFaDigits(conds)} شرط`);
if (row.config.chainMode === 'branches') {
const n = splitAutomationBranches(list).branches.length;
if (n) parts.push(`${toFaDigits(n)} شاخه`);
}
return parts.join(' · ');
}
async function load() {
loading.value = true;
error.value = '';
try {
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);
else if (!cur) hydrate(items.value[0] ?? null);
}
} catch (e) {
error.value = errText(e, 'بارگذاری اتوماسیون ناموفق بود.');
} finally {
loading.value = false;
}
}
async function save() {
const action = firstAutomationAction(steps.value);
if (!name.value.trim() || !action) {
error.value = !action ? 'حداقل یک عمل در زنجیره لازم است.' : 'نام قانون را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
ok.value = '';
try {
const payload: CreateAutomationRuleRequest = {
name: name.value.trim(),
trigger: trigger.value,
action,
isActive: isActive.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);
ok.value = 'قانون به‌روز شد.';
} else {
const { data } = await api.post<AutomationRuleDto>('/automations', payload);
editingId.value = data.id;
ok.value = 'قانون ساخته شد.';
}
markClean();
await load();
} catch (e) {
error.value = errText(e, 'ذخیره قانون ناموفق بود.');
} finally {
saving.value = false;
}
}
async function removeRule(id: string) {
if (!confirm('قانون حذف شود؟')) return;
try {
await api.delete(`/automations/${id}`);
if (editingId.value === id) hydrate(null);
await load();
} catch (e) {
error.value = errText(e, 'حذف ناموفق بود.');
}
}
async function toggleActive(row: AutomationRuleDto) {
try {
await api.patch(`/automations/${row.id}`, { isActive: !row.isActive });
if (editingId.value === row.id) isActive.value = !row.isActive;
await load();
} catch (e) {
error.value = errText(e, 'تغییر وضعیت ناموفق بود.');
}
}
onMounted(async () => {
await load();
if (editingId.value) return;
hydrate(items.value[0] ?? null);
});
watch(trigger, (next, prev) => {
if (prev === 'lead_submitted' && next !== 'lead_submitted') sourceId.value = null;
const allowed = allowedConditionFields(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;
Object.assign(step, conditionDefaults(fallback));
}
});
</script>
<template>
<section class="crm-page">
<UiPageHeader
title="اتوماسیون"
lead="مثل یک بوم کاری: گره «اگر» را به چند عمل پشت‌سرهم وصل کنید."
>
<template #actions>
<UiButton variant="ghost" :disabled="loading" @click="load">
بروزرسانی
</UiButton>
<UiButton variant="primary" @click="startNew">
<UiIcon name="plus" :size="16" />
قانون جدید
</UiButton>
</template>
</UiPageHeader>
<p v-if="error" class="error-msg">{{ error }}</p>
<p v-if="ok" class="ok-msg">{{ ok }}</p>
<div class="workspace">
<aside class="rules">
<div class="rules-head">
<h2>قوانین</h2>
<span class="counts">
{{ toFaDigits(items.length) }} قانون · {{ toFaDigits(activeCount) }} فعال
</span>
</div>
<UiBadge :variant="advanced ? 'accent' : 'neutral'" size="sm" class="level">
{{ advanced ? 'اتوماسیون حرفه‌ای' : 'اتوماسیون ساده' }}
</UiBadge>
<UiInput
v-if="items.length > 5"
v-model="search"
placeholder="جستجوی قانون…"
class="rules-search"
/>
<ul>
<li v-if="!editingId" class="on draft">
<div class="pick">
<span class="pick-top">
<strong>{{ name.trim() || 'قانون جدید' }}</strong>
<UiBadge variant="warning" size="sm">ذخیره نشده</UiBadge>
</span>
<span class="chain-preview">{{ AUTOMATION_TRIGGER_LABELS[trigger] }}</span>
</div>
</li>
<li
v-for="row in filteredItems"
:key="row.id"
:class="{ on: editingId === row.id, off: !row.isActive }"
>
<button class="pick" type="button" @click="openRule(row)">
<span class="pick-top">
<span class="dot" :class="{ live: row.isActive }" />
<strong>{{ row.name }}</strong>
<UiBadge v-if="editingId === row.id && dirty" variant="warning" size="sm">
تغییر داده شده
</UiBadge>
</span>
<span class="chain-preview">
{{ AUTOMATION_TRIGGER_LABELS[row.trigger] ?? row.trigger }}
</span>
<span class="chain-meta">{{ ruleSummary(row) }}</span>
</button>
<div class="row-actions">
<button
type="button"
class="icon-btn"
:title="row.isActive ? 'غیرفعال کردن' : 'فعال کردن'"
@click="toggleActive(row)"
>
<span class="switch" :class="{ on: row.isActive }" />
</button>
<button
type="button"
class="icon-btn danger"
title="حذف قانون"
@click="removeRule(row.id)"
>
<UiIcon name="trash" :size="15" />
</button>
</div>
</li>
</ul>
<p v-if="!items.length && !loading" class="muted">
هنوز قانونی نیست؛ از «قانون جدید» شروع کنید.
</p>
<p v-else-if="items.length && !filteredItems.length" class="muted">موردی پیدا نشد.</p>
</aside>
<div class="studio">
<p v-if="needsAdvanced" class="warn-msg">
این قانون از شاخه، شرط روی پاسخ فرم یا تقسیم نوبتی استفاده می‌کند که فقط در اتوماسیون
حرفه‌ای فعال است. برای ذخیره، این موارد را بردارید یا از پشتیبانی ارتقا بخواهید.
</p>
<div class="toolbar">
<div class="name-field">
<span class="name-label">نام قانون:</span>
<UiInput v-model="name" required minlength="2" maxlength="120" placeholder="نام قانون اتوماسیون…" />
</div>
<label class="check">
<input v-model="isActive" type="checkbox" />
<span>فعال</span>
</label>
<UiButton
variant="primary"
type="button"
:disabled="saving || !canSave"
:loading="saving"
@click="save"
>
{{ saving ? 'در حال ذخیره…' : editingId ? 'ذخیره قانون' : 'ساخت قانون' }}
</UiButton>
</div>
<div class="stage">
<AutomationCanvas
:trigger="trigger"
:steps="steps"
:selected="selected"
:chain-mode="chainMode"
:form="selectedForm"
:users="users"
:teams="teams"
@select="selected = $event"
@add="addStep"
@remove="removeStep"
@remove-range="removeRange"
@add-branch="addBranch"
/>
<AutomationInspector
v-model:trigger="trigger"
v-model:steps="steps"
v-model:source-id="sourceId"
v-model:chain-mode="chainMode"
:advanced="advanced"
:selected="selected"
:users="users"
:forms="forms"
:teams="teams"
:activity-type-defs="activityTypeDefs"
/>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.error-msg {
color: var(--color-danger);
font-size: var(--fs-sm);
font-weight: 600;
margin: 0;
}
.ok-msg {
color: var(--color-success);
font-size: var(--fs-sm);
font-weight: 600;
margin: 0;
}
.workspace {
display: grid;
grid-template-columns: 1fr;
gap: var(--sp-4);
align-items: start;
min-width: 0;
}
.rules,
.studio {
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
background: var(--color-surface);
box-shadow: var(--shadow-sm);
}
.rules {
padding: var(--sp-3);
display: grid;
gap: var(--sp-2);
align-content: start;
}
.rules-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--sp-2);
padding-inline: var(--sp-1);
}
.rules-head h2 {
margin: 0;
font-size: var(--fs-base);
font-weight: 700;
}
.counts {
color: var(--color-text-muted);
font-size: var(--fs-xs);
}
.level {
justify-self: start;
}
.rules ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.35rem;
max-height: 60vh;
overflow-y: auto;
}
.rules ul:empty {
display: none;
}
.rules li {
position: relative;
display: flex;
align-items: center;
gap: var(--sp-1);
border: 1px solid transparent;
border-inline-start: 3px solid transparent;
border-radius: var(--radius);
padding: 0.5rem 0.6rem;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.rules li:hover {
background: var(--color-surface-muted, #f8fafc);
}
.rules li.on {
border-color: var(--color-primary);
background: var(--color-primary-surface);
}
.rules li.draft {
border-style: dashed;
border-inline-start-style: solid;
}
.rules li.off .pick strong {
color: var(--color-text-muted);
}
.pick {
flex: 1;
min-width: 0;
display: grid;
gap: 0.1rem;
text-align: start;
border: 0;
background: none;
padding: 0;
cursor: pointer;
font: inherit;
color: inherit;
}
.pick-top {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.pick-top strong {
font-size: var(--fs-sm);
color: var(--color-text-emphasis);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
background: #cbd5e1;
flex-shrink: 0;
}
.dot.live {
background: #16a34a;
}
.chain-preview {
color: var(--color-text);
font-size: var(--fs-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chain-meta {
color: var(--color-text-muted);
font-size: 0.7rem;
}
.row-actions {
display: flex;
align-items: center;
gap: 0.15rem;
flex-shrink: 0;
}
.icon-btn {
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border: 0;
border-radius: var(--radius);
background: none;
color: var(--color-text-muted);
cursor: pointer;
}
.icon-btn:hover {
background: rgba(15, 23, 42, 0.06);
}
.icon-btn.danger {
opacity: 0;
}
.rules li:hover .icon-btn.danger,
.rules li.on .icon-btn.danger,
.icon-btn.danger:focus-visible {
opacity: 1;
color: var(--color-danger);
}
.switch {
position: relative;
width: 1.9rem;
height: 1.05rem;
border-radius: 999px;
background: #cbd5e1;
transition: background var(--transition-fast);
}
.switch::after {
content: '';
position: absolute;
top: 0.15rem;
inset-inline-start: 0.15rem;
width: 0.75rem;
height: 0.75rem;
border-radius: 999px;
background: #fff;
transition: inset-inline-start var(--transition-fast);
}
.switch.on {
background: #16a34a;
}
.switch.on::after {
inset-inline-start: 1rem;
}
.warn-msg {
margin: 0;
padding: var(--sp-2) var(--sp-3);
border: 1px solid #fcd34d;
border-radius: var(--radius);
background: #fffbeb;
color: #92400e;
font-size: var(--fs-sm);
}
.studio {
padding: var(--sp-4);
display: grid;
gap: var(--sp-3);
min-width: 0;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: var(--sp-3);
align-items: center;
}
.name-field {
display: flex;
align-items: center;
gap: var(--sp-2);
flex: 1 1 16rem;
}
.name-label {
font-size: var(--fs-sm);
font-weight: 600;
color: var(--color-text-muted);
white-space: nowrap;
}
.check {
display: flex;
align-items: center;
gap: var(--sp-2);
color: var(--color-text);
font-size: var(--fs-sm);
cursor: pointer;
}
.stage {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--sp-3);
min-width: 0;
}
.stage :deep(.canvas) {
width: 100%;
min-width: 0;
}
.muted {
color: var(--color-text-muted);
font-size: var(--fs-sm);
}
@media (min-width: 1100px) {
.workspace {
grid-template-columns: minmax(15rem, 18rem) minmax(0, 1fr);
}
.rules {
position: sticky;
top: var(--sp-3);
}
.rules ul {
max-height: calc(100vh - 16rem);
}
}
@media (max-width: 1099px) {
.rules ul {
max-height: 16rem;
}
}
</style>