- Implement unified design system tokens, 10px radius, and IRANSans typography - Modernize sidebar with Gemini-style collapsed expander, square 1:1 items, and elevated profile flyout - Standardize all modal dialogs with 16px padding, fixed header/footer, 16px medium title, and sticky bottom actions - Refactor DealFormView and CustomFieldsSection with standard UiField, UiInput, and UiSelect components - Add draggable bottom-left QuickCreate floating action button
606 lines
15 KiB
Vue
606 lines
15 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 {
|
|
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 = 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,
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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 chainLabel(row: AutomationRuleDto) {
|
|
const t = AUTOMATION_TRIGGER_LABELS[row.trigger] ?? row.trigger;
|
|
const a = AUTOMATION_ACTION_LABELS[row.action] ?? row.action;
|
|
return `${t} ← ${a}`;
|
|
}
|
|
|
|
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 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),
|
|
};
|
|
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);
|
|
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;
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<section class="crm-page">
|
|
<UiPageHeader
|
|
title="اتوماسیون"
|
|
subtitle="مثل یک بوم کاری: گره «اگر» را به چند عمل پشتسرهم وصل کنید."
|
|
>
|
|
<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>
|
|
<UiBadge variant="neutral" size="sm">{{ items.length }} قانون</UiBadge>
|
|
</div>
|
|
<ul>
|
|
<li
|
|
v-for="row in items"
|
|
:key="row.id"
|
|
:class="{ on: editingId === row.id }"
|
|
>
|
|
<button class="pick" type="button" @click="openRule(row)">
|
|
<div class="pick-top">
|
|
<strong>{{ row.name }}</strong>
|
|
<UiBadge :variant="row.isActive ? 'success' : 'neutral'" size="sm" dot>
|
|
{{ row.isActive ? 'فعال' : 'غیرفعال' }}
|
|
</UiBadge>
|
|
</div>
|
|
<span class="chain-preview">{{ chainLabel(row) }}</span>
|
|
</button>
|
|
<div class="mini">
|
|
<UiButton variant="ghost" size="sm" @click="toggleActive(row)">
|
|
{{ row.isActive ? 'غیرفعالسازی' : 'فعالسازی' }}
|
|
</UiButton>
|
|
<UiButton variant="danger" size="sm" @click="removeRule(row.id)">
|
|
حذف
|
|
</UiButton>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
<p v-if="!items.length && !loading" class="muted">هنوز قانونی نیست.</p>
|
|
</aside>
|
|
|
|
<div class="studio">
|
|
<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"
|
|
@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>
|
|
.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-4);
|
|
display: grid;
|
|
gap: var(--sp-3);
|
|
align-content: start;
|
|
}
|
|
|
|
.rules-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.rules-head h2 {
|
|
margin: 0;
|
|
font-size: var(--fs-base);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.rules ul {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: grid;
|
|
gap: var(--sp-2);
|
|
}
|
|
|
|
.rules li {
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-lg);
|
|
padding: var(--sp-3);
|
|
display: grid;
|
|
gap: var(--sp-2);
|
|
transition: all var(--transition-fast);
|
|
}
|
|
|
|
.rules li.on {
|
|
border-color: var(--color-primary);
|
|
background: var(--color-primary-surface);
|
|
}
|
|
|
|
.pick {
|
|
display: grid;
|
|
gap: var(--sp-1);
|
|
text-align: start;
|
|
border: 0;
|
|
background: none;
|
|
padding: 0;
|
|
cursor: pointer;
|
|
font: inherit;
|
|
color: inherit;
|
|
width: 100%;
|
|
}
|
|
|
|
.pick-top {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: var(--sp-2);
|
|
width: 100%;
|
|
}
|
|
|
|
.pick-top strong {
|
|
font-size: var(--fs-sm);
|
|
color: var(--color-text-emphasis);
|
|
}
|
|
|
|
.chain-preview {
|
|
color: var(--color-text-muted);
|
|
font-size: var(--fs-xs);
|
|
}
|
|
|
|
.mini {
|
|
display: flex;
|
|
gap: var(--sp-2);
|
|
justify-content: flex-end;
|
|
border-top: 1px dashed var(--color-border);
|
|
padding-top: var(--sp-2);
|
|
}
|
|
|
|
.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: 1280px) {
|
|
.workspace {
|
|
grid-template-columns: minmax(15rem, 20rem) minmax(0, 1fr);
|
|
grid-template-areas:
|
|
'rules toolbar'
|
|
'stage stage';
|
|
}
|
|
|
|
.rules {
|
|
grid-area: rules;
|
|
height: 100%;
|
|
}
|
|
|
|
.studio {
|
|
display: contents;
|
|
border: 0;
|
|
padding: 0;
|
|
box-shadow: none;
|
|
background: transparent;
|
|
}
|
|
|
|
.toolbar {
|
|
grid-area: toolbar;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: var(--radius);
|
|
background: #fff;
|
|
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
|
padding: 0.85rem 1rem;
|
|
align-items: end;
|
|
}
|
|
|
|
.stage {
|
|
grid-area: stage;
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
}
|
|
|
|
@media (max-width: 1279px) {
|
|
.rules ul {
|
|
display: flex;
|
|
overflow-x: auto;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.rules li {
|
|
min-width: 13rem;
|
|
flex: 0 0 auto;
|
|
}
|
|
}
|
|
</style>
|