597 lines
14 KiB
Vue
597 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref, watch } from 'vue';
|
||
import axios from 'axios';
|
||
import { api } from '@/lib/api';
|
||
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 {
|
||
AutomationAction,
|
||
AutomationRuleDto,
|
||
AutomationStep,
|
||
AutomationTrigger,
|
||
CreateAutomationRuleRequest,
|
||
OrgUserDto,
|
||
} from '@mento/shared';
|
||
import {
|
||
AUTOMATION_ACTION_LABELS,
|
||
AUTOMATION_TRIGGER_LABELS,
|
||
MAX_AUTOMATION_STEPS,
|
||
conditionFieldsForTrigger,
|
||
firstAutomationAction,
|
||
isAutomationCondition,
|
||
normalizeAutomationSteps,
|
||
packAutomationConfig,
|
||
} from '@mento/shared';
|
||
|
||
const loading = ref(false);
|
||
const saving = ref(false);
|
||
const error = ref('');
|
||
const ok = ref('');
|
||
const items = ref<AutomationRuleDto[]>([]);
|
||
const users = ref<OrgUserDto[]>([]);
|
||
|
||
const editingId = ref<string | null>(null);
|
||
const name = ref('قانون بدون نام');
|
||
const trigger = ref<AutomationTrigger>('deal_won');
|
||
const isActive = ref(true);
|
||
const steps = ref<AutomationStep[]>([makeStep('create_activity')]);
|
||
const selected = ref<'trigger' | string>('trigger');
|
||
const savedSnap = ref('');
|
||
|
||
const dirty = computed(() => snap() !== savedSnap.value);
|
||
const canSave = computed(
|
||
() => name.value.trim().length >= 2 && Boolean(firstAutomationAction(steps.value)),
|
||
);
|
||
|
||
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,
|
||
};
|
||
}
|
||
|
||
function makeCondition(): AutomationStep {
|
||
const field =
|
||
trigger.value === 'deal_won' ? 'deal_amount' : 'ticket_priority';
|
||
return {
|
||
id: `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||
kind: 'condition',
|
||
conditionField: field,
|
||
conditionOp: field === 'deal_amount' ? 'gte' : 'eq',
|
||
conditionValue: field === 'deal_amount' ? 0 : 'normal',
|
||
};
|
||
}
|
||
|
||
function snap() {
|
||
return JSON.stringify({
|
||
name: name.value.trim(),
|
||
trigger: trigger.value,
|
||
isActive: isActive.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;
|
||
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;
|
||
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),
|
||
}));
|
||
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);
|
||
ok.value = '';
|
||
}
|
||
|
||
function openRule(row: AutomationRuleDto) {
|
||
if (editingId.value === row.id) return;
|
||
if (!confirmLeave()) return;
|
||
hydrate(row);
|
||
ok.value = '';
|
||
}
|
||
|
||
function addStep(payload: AutomationAddPayload, index: number) {
|
||
if (steps.value.length >= MAX_AUTOMATION_STEPS) return;
|
||
const step = payload.kind === 'condition' ? makeCondition() : makeStep(payload.action);
|
||
steps.value.splice(index, 0, step);
|
||
selected.value = step.id;
|
||
}
|
||
|
||
function removeStep(id: string) {
|
||
const target = steps.value.find((s) => s.id === id);
|
||
if (!target) return;
|
||
const actionCount = steps.value.filter((s) => !isAutomationCondition(s)).length;
|
||
if (!isAutomationCondition(target) && actionCount <= 1) return;
|
||
if (steps.value.length <= 1) return;
|
||
steps.value = steps.value.filter((s) => s.id !== id);
|
||
if (selected.value === id) selected.value = 'trigger';
|
||
}
|
||
|
||
function chainLabel(row: AutomationRuleDto) {
|
||
const chain = normalizeAutomationSteps(row.action, row.config);
|
||
if (chain.length <= 1) {
|
||
return `${AUTOMATION_TRIGGER_LABELS[row.trigger]} → ${AUTOMATION_ACTION_LABELS[row.action]}`;
|
||
}
|
||
return `${AUTOMATION_TRIGGER_LABELS[row.trigger]} → ${toFaDigits(chain.length)} عمل`;
|
||
}
|
||
|
||
async function load() {
|
||
loading.value = true;
|
||
error.value = '';
|
||
try {
|
||
const [rulesRes, usersRes] = await Promise.all([
|
||
api.get<AutomationRuleDto[]>('/automations'),
|
||
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
|
||
]);
|
||
items.value = rulesRes.data;
|
||
users.value = usersRes.data;
|
||
if (editingId.value) {
|
||
const current = items.value.find((r) => r.id === editingId.value);
|
||
if (current && !dirty.value) hydrate(current);
|
||
}
|
||
} catch (e) {
|
||
error.value = errText(e, 'بارگذاری اتوماسیون ناموفق بود.');
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function save() {
|
||
const action = firstAutomationAction(steps.value);
|
||
if (!canSave.value || !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),
|
||
};
|
||
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) => {
|
||
const allowed = conditionFieldsForTrigger(next);
|
||
for (const step of steps.value) {
|
||
if (!isAutomationCondition(step)) continue;
|
||
if (step.conditionField && allowed.includes(step.conditionField)) continue;
|
||
step.conditionField = next === 'deal_won' ? 'deal_amount' : 'ticket_priority';
|
||
step.conditionOp = next === 'deal_won' ? 'gte' : 'eq';
|
||
step.conditionValue = next === 'deal_won' ? 0 : 'normal';
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<section class="page">
|
||
<header class="page-head">
|
||
<div class="head-copy">
|
||
<h1>اتوماسیون</h1>
|
||
<p class="lead">
|
||
مثل یک بوم کاری: گره «اگر» را به چند عمل پشتسرهم وصل کنید.
|
||
</p>
|
||
</div>
|
||
<div class="head-actions">
|
||
<button class="btn ghost" type="button" :disabled="loading" @click="load">
|
||
بروزرسانی
|
||
</button>
|
||
<button class="btn primary" type="button" @click="startNew">
|
||
<UiIcon name="plus" :size="16" />
|
||
قانون جدید
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<p v-if="error" class="error">{{ error }}</p>
|
||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||
|
||
<div class="workspace">
|
||
<aside class="rules">
|
||
<h2>قوانین</h2>
|
||
<ul>
|
||
<li
|
||
v-for="row in items"
|
||
:key="row.id"
|
||
:class="{ on: editingId === row.id }"
|
||
>
|
||
<button class="pick" type="button" @click="openRule(row)">
|
||
<strong>{{ row.name }}</strong>
|
||
<span>{{ chainLabel(row) }}</span>
|
||
</button>
|
||
<div class="mini">
|
||
<button type="button" @click="toggleActive(row)">
|
||
{{ row.isActive ? 'فعال' : 'غیرفعال' }}
|
||
</button>
|
||
<button class="danger" type="button" @click="removeRule(row.id)">
|
||
حذف
|
||
</button>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
<p v-if="!items.length && !loading" class="muted">هنوز قانونی نیست.</p>
|
||
</aside>
|
||
|
||
<div class="studio">
|
||
<div class="toolbar">
|
||
<label class="name">
|
||
نام قانون
|
||
<input v-model="name" type="text" required minlength="2" maxlength="120" />
|
||
</label>
|
||
<label class="check">
|
||
<input v-model="isActive" type="checkbox" />
|
||
فعال
|
||
</label>
|
||
<button
|
||
class="btn primary"
|
||
type="button"
|
||
:disabled="saving || !canSave"
|
||
@click="save"
|
||
>
|
||
{{ saving ? 'در حال ذخیره…' : editingId ? 'ذخیره' : 'ساخت' }}
|
||
</button>
|
||
</div>
|
||
|
||
<div class="stage">
|
||
<AutomationCanvas
|
||
:trigger="trigger"
|
||
:steps="steps"
|
||
:selected="selected"
|
||
@select="selected = $event"
|
||
@add="addStep"
|
||
@remove="removeStep"
|
||
/>
|
||
<AutomationInspector
|
||
v-model:trigger="trigger"
|
||
v-model:steps="steps"
|
||
:selected="selected"
|
||
:users="users"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.page {
|
||
display: grid;
|
||
gap: 1rem;
|
||
min-width: 0;
|
||
}
|
||
|
||
.page-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 1rem;
|
||
flex-wrap: wrap;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.head-copy {
|
||
min-width: 0;
|
||
}
|
||
|
||
.head-actions {
|
||
display: flex;
|
||
gap: 0.55rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
h1 {
|
||
margin: 0 0 0.3rem;
|
||
font-size: clamp(1.45rem, 2.5vw, 1.85rem);
|
||
font-weight: 800;
|
||
}
|
||
|
||
.lead,
|
||
.muted {
|
||
margin: 0;
|
||
color: #64748b;
|
||
}
|
||
|
||
.workspace {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
gap: 0.9rem;
|
||
align-items: start;
|
||
}
|
||
|
||
.rules,
|
||
.studio {
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 18px;
|
||
background: #fff;
|
||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
||
}
|
||
|
||
.rules {
|
||
padding: 1rem;
|
||
display: grid;
|
||
gap: 0.7rem;
|
||
align-content: start;
|
||
}
|
||
|
||
h2 {
|
||
margin: 0;
|
||
font-size: 1rem;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.rules ul {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: grid;
|
||
gap: 0.45rem;
|
||
}
|
||
|
||
.rules li {
|
||
border: 1px solid #e8eef3;
|
||
border-radius: 14px;
|
||
padding: 0.55rem 0.65rem 0.45rem;
|
||
display: grid;
|
||
gap: 0.35rem;
|
||
}
|
||
|
||
.rules li.on {
|
||
border-color: #99f6e4;
|
||
background: #f0fdfa;
|
||
}
|
||
|
||
.pick {
|
||
display: grid;
|
||
gap: 0.15rem;
|
||
text-align: start;
|
||
border: 0;
|
||
background: none;
|
||
padding: 0;
|
||
cursor: pointer;
|
||
font: inherit;
|
||
color: inherit;
|
||
}
|
||
|
||
.pick strong {
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.pick span {
|
||
color: #64748b;
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
.mini {
|
||
display: flex;
|
||
gap: 0.65rem;
|
||
}
|
||
|
||
.mini button {
|
||
border: 0;
|
||
background: none;
|
||
color: #0f766e;
|
||
cursor: pointer;
|
||
font: inherit;
|
||
font-size: 0.78rem;
|
||
font-weight: 700;
|
||
padding: 0;
|
||
}
|
||
|
||
.mini .danger {
|
||
color: #b91c1c;
|
||
}
|
||
|
||
.studio {
|
||
padding: 0.85rem;
|
||
display: grid;
|
||
gap: 0.75rem;
|
||
min-width: 0;
|
||
}
|
||
|
||
.toolbar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.7rem;
|
||
align-items: end;
|
||
}
|
||
|
||
.name {
|
||
display: grid;
|
||
gap: 0.3rem;
|
||
font-size: 0.82rem;
|
||
color: #64748b;
|
||
flex: 1 1 14rem;
|
||
}
|
||
|
||
.name input {
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 12px;
|
||
padding: 0.55rem 0.7rem;
|
||
font: inherit;
|
||
color: #0f172a;
|
||
}
|
||
|
||
.check {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
color: #334155;
|
||
font-size: 0.88rem;
|
||
padding-bottom: 0.45rem;
|
||
}
|
||
|
||
.stage {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
gap: 0.75rem;
|
||
min-width: 0;
|
||
}
|
||
|
||
.btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 0.35rem;
|
||
min-height: 42px;
|
||
padding: 0.55rem 1rem;
|
||
border-radius: 12px;
|
||
font-weight: 700;
|
||
border: 1px solid transparent;
|
||
cursor: pointer;
|
||
font: inherit;
|
||
}
|
||
|
||
.btn.primary {
|
||
background: #14b8a6;
|
||
color: #fff;
|
||
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.24);
|
||
}
|
||
|
||
.btn.primary:disabled {
|
||
opacity: 0.55;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.btn.ghost {
|
||
background: #fff;
|
||
color: #0f766e;
|
||
border-color: #99f6e4;
|
||
}
|
||
|
||
.error {
|
||
margin: 0;
|
||
color: #b91c1c;
|
||
}
|
||
|
||
.ok {
|
||
margin: 0;
|
||
color: #0f766e;
|
||
font-weight: 600;
|
||
}
|
||
|
||
@media (min-width: 1280px) {
|
||
.workspace {
|
||
grid-template-columns: minmax(15rem, 17.5rem) minmax(0, 1fr);
|
||
}
|
||
|
||
.stage {
|
||
grid-template-columns: minmax(0, 1fr) minmax(16rem, 19rem);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 1279px) {
|
||
.rules ul {
|
||
display: flex;
|
||
overflow-x: auto;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.rules li {
|
||
min-width: 13rem;
|
||
flex: 0 0 auto;
|
||
}
|
||
}
|
||
</style>
|