Give automations a visual chain with conditions and SMS, and match the pipeline board to the sales mockup.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -115,6 +115,11 @@ const listEmpty = computed(() => !loading.value && !items.value.length);
|
||||
const rangeFrom = computed(() => (total.value ? (page.value - 1) * PAGE_SIZE + 1 : 0));
|
||||
const rangeTo = computed(() => Math.min(page.value * PAGE_SIZE, total.value));
|
||||
|
||||
function isLastItem(id: string) {
|
||||
const rows = items.value;
|
||||
return rows.length > 0 && rows[rows.length - 1]?.id === id;
|
||||
}
|
||||
|
||||
function isOverdue(item: ActivityDto) {
|
||||
if (item.status !== 'open' || !item.dueAt) return false;
|
||||
return startOfLocalDay(new Date(item.dueAt)).getTime() < startOfLocalDay().getTime();
|
||||
@@ -393,7 +398,13 @@ onMounted(() => {
|
||||
<span>وضعیت</span>
|
||||
<span />
|
||||
</div>
|
||||
<article v-for="item in items" :key="item.id" class="trow" role="row">
|
||||
<article
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="trow"
|
||||
:class="{ 'menu-open': menuId === item.id }"
|
||||
role="row"
|
||||
>
|
||||
<div class="title-cell">
|
||||
<UiIcon name="star" :size="16" class="star" />
|
||||
<div>
|
||||
@@ -435,7 +446,11 @@ onMounted(() => {
|
||||
>
|
||||
<UiIcon name="dots-v" :size="16" />
|
||||
</button>
|
||||
<div v-if="menuId === item.id" class="popover">
|
||||
<div
|
||||
v-if="menuId === item.id"
|
||||
class="popover"
|
||||
:class="{ 'drop-up': isLastItem(item.id) }"
|
||||
>
|
||||
<RouterLink :to="{ name: 'activity-detail', params: { id: item.id } }" @click="menuId = null">
|
||||
مشاهده
|
||||
</RouterLink>
|
||||
@@ -484,7 +499,12 @@ onMounted(() => {
|
||||
<UiIcon :name="typeIcon[item.type]" :size="18" />
|
||||
</span>
|
||||
<span class="type-pill" :class="item.type">{{ typeLabel[item.type] }}</span>
|
||||
<div v-if="menuId === item.id" class="popover" @click.stop>
|
||||
<div
|
||||
v-if="menuId === item.id"
|
||||
class="popover"
|
||||
:class="{ 'drop-up': isLastItem(item.id) }"
|
||||
@click.stop
|
||||
>
|
||||
<RouterLink :to="{ name: 'activity-detail', params: { id: item.id } }" @click="menuId = null">
|
||||
مشاهده
|
||||
</RouterLink>
|
||||
@@ -709,7 +729,12 @@ h1 {
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
|
||||
padding: 0.35rem;
|
||||
display: grid;
|
||||
z-index: 8;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.popover.drop-up {
|
||||
top: auto;
|
||||
bottom: calc(100% + 6px);
|
||||
}
|
||||
|
||||
.popover a,
|
||||
@@ -789,7 +814,7 @@ h1 {
|
||||
.table {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
@@ -807,12 +832,23 @@ h1 {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
border-radius: 18px 18px 0 0;
|
||||
}
|
||||
|
||||
.trow {
|
||||
position: relative;
|
||||
border-bottom: 1px solid #f8fafc;
|
||||
}
|
||||
|
||||
.trow:last-child {
|
||||
border-bottom: 0;
|
||||
border-radius: 0 0 18px 18px;
|
||||
}
|
||||
|
||||
.trow.menu-open {
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
.title-cell {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
|
||||
@@ -901,22 +901,33 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
|
||||
.priorities {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.25rem;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.45rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pri {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.68rem;
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.28rem;
|
||||
font-size: 0.72rem;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.pri input {
|
||||
accent-color: #14b8a6;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pri.very-high {
|
||||
@@ -1183,7 +1194,11 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
|
||||
.priorities {
|
||||
justify-content: flex-start;
|
||||
gap: 1.1rem;
|
||||
gap: 1.15rem;
|
||||
}
|
||||
|
||||
.pri {
|
||||
gap: 0.32rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,86 +1,202 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
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 AUTOMATION_TRIGGER_LABELS: Record<AutomationTrigger, string> = {
|
||||
deal_won: 'معامله برنده شد',
|
||||
ticket_resolved: 'تیکت حل شد',
|
||||
};
|
||||
|
||||
const AUTOMATION_ACTION_LABELS: Record<AutomationAction, string> = {
|
||||
create_activity: 'ساخت فعالیت',
|
||||
create_notification: 'ارسال اعلان',
|
||||
add_loyalty_points: 'افزودن امتیاز باشگاه',
|
||||
};
|
||||
|
||||
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 name = ref('قانون بدون نام');
|
||||
const trigger = ref<AutomationTrigger>('deal_won');
|
||||
const action = ref<AutomationAction>('create_activity');
|
||||
const isActive = ref(true);
|
||||
const title = ref('');
|
||||
const body = ref('');
|
||||
const points = ref(100);
|
||||
const steps = ref<AutomationStep[]>([makeStep('create_activity')]);
|
||||
const selected = ref<'trigger' | string>('trigger');
|
||||
const savedSnap = ref('');
|
||||
|
||||
const triggers = Object.keys(AUTOMATION_TRIGGER_LABELS) as AutomationTrigger[];
|
||||
const actions = Object.keys(AUTOMATION_ACTION_LABELS) as AutomationAction[];
|
||||
const dirty = computed(() => snap() !== savedSnap.value);
|
||||
const canSave = computed(
|
||||
() => name.value.trim().length >= 2 && Boolean(firstAutomationAction(steps.value)),
|
||||
);
|
||||
|
||||
function resetForm() {
|
||||
editingId.value = null;
|
||||
name.value = '';
|
||||
trigger.value = 'deal_won';
|
||||
action.value = 'create_activity';
|
||||
isActive.value = true;
|
||||
title.value = '';
|
||||
body.value = '';
|
||||
points.value = 100;
|
||||
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 startEdit(row: AutomationRuleDto) {
|
||||
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;
|
||||
action.value = row.action;
|
||||
isActive.value = row.isActive;
|
||||
title.value = row.config.title ?? '';
|
||||
body.value = row.config.body ?? '';
|
||||
points.value = row.config.points ?? 100;
|
||||
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 { data } = await api.get<AutomationRuleDto[]>('/automations');
|
||||
items.value = data;
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e) && !e.response) {
|
||||
error.value = 'اتصال به API برقرار نشد.';
|
||||
} else {
|
||||
error.value = 'بارگذاری اتوماسیون ناموفق بود.';
|
||||
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() {
|
||||
if (name.value.trim().length < 2) {
|
||||
error.value = 'نام قانون را وارد کنید.';
|
||||
const action = firstAutomationAction(steps.value);
|
||||
if (!canSave.value || !action) {
|
||||
error.value = !action ? 'حداقل یک عمل در زنجیره لازم است.' : 'نام قانون را وارد کنید.';
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
@@ -90,28 +206,22 @@ async function save() {
|
||||
const payload: CreateAutomationRuleRequest = {
|
||||
name: name.value.trim(),
|
||||
trigger: trigger.value,
|
||||
action: action.value,
|
||||
action,
|
||||
isActive: isActive.value,
|
||||
config: {
|
||||
title: title.value.trim() || undefined,
|
||||
body: body.value.trim() || undefined,
|
||||
points:
|
||||
action.value === 'add_loyalty_points'
|
||||
? Math.max(1, Math.floor(points.value))
|
||||
: undefined,
|
||||
},
|
||||
config: packAutomationConfig(steps.value),
|
||||
};
|
||||
if (editingId.value) {
|
||||
await api.patch(`/automations/${editingId.value}`, payload);
|
||||
ok.value = 'قانون بهروز شد.';
|
||||
} else {
|
||||
await api.post('/automations', payload);
|
||||
const { data } = await api.post<AutomationRuleDto>('/automations', payload);
|
||||
editingId.value = data.id;
|
||||
ok.value = 'قانون ساخته شد.';
|
||||
}
|
||||
resetForm();
|
||||
markClean();
|
||||
await load();
|
||||
} catch {
|
||||
error.value = 'ذخیره قانون ناموفق بود.';
|
||||
} catch (e) {
|
||||
error.value = errText(e, 'ذخیره قانون ناموفق بود.');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
@@ -121,121 +231,128 @@ async function removeRule(id: string) {
|
||||
if (!confirm('قانون حذف شود؟')) return;
|
||||
try {
|
||||
await api.delete(`/automations/${id}`);
|
||||
if (editingId.value === id) hydrate(null);
|
||||
await load();
|
||||
} catch {
|
||||
error.value = 'حذف ناموفق بود.';
|
||||
} 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 {
|
||||
error.value = 'تغییر وضعیت ناموفق بود.';
|
||||
} catch (e) {
|
||||
error.value = errText(e, 'تغییر وضعیت ناموفق بود.');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
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>
|
||||
<h1>اتوماسیون</h1>
|
||||
<p class="lead">
|
||||
بعد از بردن معامله یا حل شدن تیکت، فعالیت، اعلان یا امتیاز باشگاه بسازید.
|
||||
مثل یک بوم کاری: گره «اگر» را به چند عمل پشتسرهم وصل کنید.
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn ghost" type="button" :disabled="loading" @click="load">
|
||||
بروزرسانی
|
||||
</button>
|
||||
<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>
|
||||
|
||||
<form class="panel form" @submit.prevent="save">
|
||||
<h2>{{ editingId ? 'ویرایش قانون' : 'قانون جدید' }}</h2>
|
||||
<label>
|
||||
نام
|
||||
<input v-model="name" type="text" required minlength="2" maxlength="120" />
|
||||
</label>
|
||||
<div class="grid2">
|
||||
<label>
|
||||
اگر (محرک)
|
||||
<select v-model="trigger">
|
||||
<option v-for="t in triggers" :key="t" :value="t">
|
||||
{{ AUTOMATION_TRIGGER_LABELS[t] }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
آنگاه (عمل)
|
||||
<select v-model="action">
|
||||
<option v-for="a in actions" :key="a" :value="a">
|
||||
{{ AUTOMATION_ACTION_LABELS[a] }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
عنوان / متن عمل
|
||||
<input v-model="title" type="text" maxlength="200" placeholder="اختیاری" />
|
||||
</label>
|
||||
<label>
|
||||
توضیح
|
||||
<textarea v-model="body" rows="3" maxlength="1000" placeholder="اختیاری" />
|
||||
</label>
|
||||
<label v-if="action === 'add_loyalty_points'">
|
||||
امتیاز
|
||||
<input v-model.number="points" type="number" min="1" />
|
||||
</label>
|
||||
<label class="check">
|
||||
<input v-model="isActive" type="checkbox" />
|
||||
فعال
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button class="btn primary" type="submit" :disabled="saving">
|
||||
{{ saving ? 'در حال ذخیره…' : editingId ? 'بهروزرسانی' : 'ساخت' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="editingId"
|
||||
class="btn ghost"
|
||||
type="button"
|
||||
@click="resetForm"
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<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>
|
||||
|
||||
<section class="panel">
|
||||
<h2>قوانین</h2>
|
||||
<ul class="list">
|
||||
<li v-for="row in items" :key="row.id">
|
||||
<div>
|
||||
<strong>{{ row.name }}</strong>
|
||||
<p class="muted">
|
||||
اگر {{ AUTOMATION_TRIGGER_LABELS[row.trigger] }} →
|
||||
{{ AUTOMATION_ACTION_LABELS[row.action] }}
|
||||
<span v-if="!row.isActive"> · غیرفعال</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<button class="link" type="button" @click="toggleActive(row)">
|
||||
{{ row.isActive ? 'غیرفعال' : 'فعال' }}
|
||||
</button>
|
||||
<button class="link" type="button" @click="startEdit(row)">ویرایش</button>
|
||||
<button class="link danger" type="button" @click="removeRule(row.id)">
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="!items.length && !loading" class="muted">هنوز قانونی نیست.</p>
|
||||
</section>
|
||||
<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>
|
||||
|
||||
@@ -243,9 +360,7 @@ onMounted(load);
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
max-width: 860px;
|
||||
min-width: 0;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
@@ -260,6 +375,12 @@ onMounted(load);
|
||||
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);
|
||||
@@ -272,57 +393,147 @@ h1 {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.panel {
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.9rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.rules,
|
||||
.studio {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 18px;
|
||||
padding: 1.1rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.rules {
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
gap: 0.7rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.form label {
|
||||
.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;
|
||||
font-size: 0.9rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font: inherit;
|
||||
color: #0f172a;
|
||||
background: #fff;
|
||||
.rules li.on {
|
||||
border-color: #99f6e4;
|
||||
background: #f0fdfa;
|
||||
}
|
||||
|
||||
.grid2 {
|
||||
.pick {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
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 !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
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;
|
||||
@@ -338,49 +549,15 @@ textarea {
|
||||
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;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.actions,
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #0f766e;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.error {
|
||||
@@ -394,16 +571,26 @@ textarea {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.grid2 {
|
||||
grid-template-columns: 1fr;
|
||||
@media (min-width: 1280px) {
|
||||
.workspace {
|
||||
grid-template-columns: minmax(15rem, 17.5rem) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.actions,
|
||||
.row-actions,
|
||||
.list li {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
.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>
|
||||
|
||||
@@ -303,9 +303,9 @@ async function load() {
|
||||
closeMenus();
|
||||
try {
|
||||
const [invRes, quoteRes, peopleRes] = await Promise.all([
|
||||
api.get<PaginatedResponse<InvoiceDto>>('/invoices', { params: { limit: 200 } }),
|
||||
api.get<PaginatedResponse<QuoteDto>>('/quotes', { params: { limit: 200 } }),
|
||||
api.get<PaginatedResponse<PersonDto>>('/people', { params: { limit: 200 } }),
|
||||
api.get<PaginatedResponse<InvoiceDto>>('/invoices', { params: { limit: 100 } }),
|
||||
api.get<PaginatedResponse<QuoteDto>>('/quotes', { params: { limit: 100 } }),
|
||||
api.get<PaginatedResponse<PersonDto>>('/people', { params: { limit: 100 } }),
|
||||
]);
|
||||
invoices.value = invRes.data.items;
|
||||
quotes.value = quoteRes.data.items;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
import PipelineToolbar from '@/components/pipeline/PipelineToolbar.vue';
|
||||
import type { PipelineViewMode } from '@/components/pipeline/PipelineToolbar.vue';
|
||||
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
|
||||
import PipelineList from '@/components/pipeline/PipelineList.vue';
|
||||
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
|
||||
import type { PipelineDeal, PipelineStage } from '@/lib/pipeline-board';
|
||||
import {
|
||||
@@ -45,7 +46,7 @@ const moving = ref(false);
|
||||
const pipelineOptions = computed(() =>
|
||||
pipelines.value.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.isDefault ? `${p.name} (پیشفرض)` : p.name,
|
||||
name: p.name,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -264,25 +265,14 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
|
||||
:selected-pipeline-id="selectedPipelineId"
|
||||
:view-mode="viewMode"
|
||||
:search="search"
|
||||
:can-create="canCreatePipeline"
|
||||
@update:view-mode="viewMode = $event"
|
||||
@update:search="search = $event"
|
||||
@update:selected-pipeline-id="selectedPipelineId = $event"
|
||||
@filter="onFilter"
|
||||
@create="showCreate = !showCreate"
|
||||
/>
|
||||
|
||||
<div v-if="canCreatePipeline" class="create-row">
|
||||
<button
|
||||
class="btn-ghost-sm"
|
||||
type="button"
|
||||
@click="showCreate = !showCreate"
|
||||
>
|
||||
کاریز جدید
|
||||
</button>
|
||||
<button class="btn-ghost-sm" type="button" :disabled="loading" @click="load">
|
||||
بروزرسانی
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-if="showCreate && canCreatePipeline"
|
||||
class="create-box"
|
||||
@@ -305,12 +295,15 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading && !stages.length" class="muted">در حال بارگذاری کاریز…</p>
|
||||
|
||||
<div v-if="viewMode === 'list'" class="list-placeholder">
|
||||
نمای لیست معاملات
|
||||
</div>
|
||||
<PipelineList
|
||||
v-if="viewMode === 'list' && (!loading || stages.length)"
|
||||
:stages="visibleStages"
|
||||
@open-deal="onOpenDeal"
|
||||
@menu-deal="onMenuDeal"
|
||||
/>
|
||||
|
||||
<PipelineBoard
|
||||
v-else-if="stages.length"
|
||||
v-else-if="viewMode === 'kanban' && stages.length"
|
||||
:stages="visibleStages"
|
||||
:dragging-id="draggingId"
|
||||
:drop-stage-id="dropStageId"
|
||||
@@ -345,16 +338,13 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.create-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
container-type: inline-size;
|
||||
container-name: pipeline;
|
||||
}
|
||||
|
||||
.btn-ghost-sm {
|
||||
@@ -373,10 +363,12 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.create-box input {
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0.65rem 0.85rem;
|
||||
@@ -384,21 +376,28 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.list-placeholder {
|
||||
margin-top: 0.5rem;
|
||||
padding: 2.5rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
background: var(--surface);
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: var(--radius);
|
||||
font-weight: 700;
|
||||
@container pipeline (max-width: 560px) {
|
||||
.create-box {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.create-box input,
|
||||
.create-box .btn,
|
||||
.create-box .btn-ghost-sm {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.menu-pop {
|
||||
position: fixed;
|
||||
inset-inline-start: 1rem;
|
||||
|
||||
Reference in New Issue
Block a user