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

597 lines
14 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, 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>