feat(leads): edit lead-board columns from the board

New-lead counts follow the first open column instead of its name, and cards no longer show an initials mark.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-29 17:23:40 +03:30
co-authored by Cursor
parent 6f42440b3b
commit 0d5e1034e5
4 changed files with 486 additions and 59 deletions
+42 -17
View File
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import type { LeadStageDto } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
const DEFAULT_STAGES: Array<{
@@ -74,20 +75,24 @@ export class LeadStagesService {
where: { organizationId },
_max: { position: true },
});
const row = await this.prisma.leadStage.create({
data: {
organizationId,
name: dto.name.trim(),
color: dto.color?.trim() || 'teal',
showOnBoard: dto.showOnBoard !== false,
isConverted: Boolean(dto.isConverted),
isLost: Boolean(dto.isLost),
isUnqualified: Boolean(dto.isUnqualified),
position: (max._max.position ?? -1) + 1,
},
include: { _count: { select: { leads: true } } },
});
return this.toDto(row);
try {
const row = await this.prisma.leadStage.create({
data: {
organizationId,
name: this.cleanName(dto.name),
color: dto.color?.trim() || 'teal',
showOnBoard: dto.showOnBoard !== false,
isConverted: Boolean(dto.isConverted),
isLost: Boolean(dto.isLost),
isUnqualified: Boolean(dto.isUnqualified),
position: (max._max.position ?? -1) + 1,
},
include: { _count: { select: { leads: true } } },
});
return this.toDto(row);
} catch (e) {
this.rethrow(e);
}
}
async update(
@@ -103,10 +108,11 @@ export class LeadStagesService {
position?: number;
},
): Promise<LeadStageDto> {
try {
await this.prisma.leadStage.updateMany({
where: { id, organizationId },
data: {
...(dto.name != null ? { name: dto.name.trim() } : {}),
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
...(dto.color != null ? { color: dto.color } : {}),
...(dto.showOnBoard != null ? { showOnBoard: dto.showOnBoard } : {}),
...(dto.isConverted != null ? { isConverted: dto.isConverted } : {}),
@@ -122,6 +128,9 @@ export class LeadStagesService {
include: { _count: { select: { leads: true } } },
});
return this.toDto(row);
} catch (e) {
this.rethrow(e);
}
}
async reorder(organizationId: string, stageIds: string[]): Promise<LeadStageDto[]> {
@@ -143,11 +152,27 @@ export class LeadStagesService {
});
if (!stage) return;
if (stage._count.leads > 0) {
throw new Error('مرحله‌ای که لید دارد قابل حذف نیست');
throw new BadRequestException(
'این ستون هنوز لید دارد و حذف نمی‌شود. لیدهای فعال و بایگانی را اول به ستون دیگری ببرید.',
);
}
await this.prisma.leadStage.delete({ where: { id } });
}
private cleanName(raw: string): string {
const name = raw?.trim() ?? '';
if (!name) throw new BadRequestException('نام ستون لازم است');
if (name.length > 80) throw new BadRequestException('نام ستون حداکثر ۸۰ کاراکتر است');
return name;
}
private rethrow(e: unknown): never {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new BadRequestException('ستونی با این نام وجود دارد');
}
throw e;
}
private toDto(row: {
id: string;
name: string;
+1
View File
@@ -107,6 +107,7 @@ export class LeadsController {
await this.stages.remove(user.organizationId, id);
return { ok: true };
} catch (e) {
if (e instanceof BadRequestException) throw e;
throw new BadRequestException(
e instanceof Error ? e.message : 'حذف مرحله ناموفق بود',
);
+5 -9
View File
@@ -979,8 +979,10 @@ export class LeadsService {
const stages = await this.stages.list(actor.organizationId);
const convertedIds = stages.filter((s) => s.isConverted).map((s) => s.id);
const lostIds = stages.filter((s) => s.isLost).map((s) => s.id);
const interested = stages.find((s) => s.name.includes('علاقه‌مند'));
const newStage = stages.find((s) => s.name === 'جدید');
// همان مقصد فرم و ورود از اکسل: اولین ستون باز، مستقل از اسم «جدید»
const newStage = stages.find(
(s) => !s.isConverted && !s.isLost && !s.isUnqualified,
);
const [
total,
@@ -988,7 +990,6 @@ export class LeadsService {
unassigned,
converted,
lost,
interestedCount,
withFollowUp,
callsLogged,
byAssigneeRaw,
@@ -1008,11 +1009,6 @@ export class LeadsService {
where: { ...base, stageId: { in: lostIds } },
})
: Promise.resolve(0),
interested
? this.prisma.lead.count({
where: { ...base, stageId: interested.id },
})
: Promise.resolve(0),
this.prisma.lead.count({
where: { ...base, nextFollowUpAt: { not: null } },
}),
@@ -1055,7 +1051,7 @@ export class LeadsService {
.sort((a, b) => b.count - a.count),
callsLogged,
withFollowUp,
interested: interestedCount,
interested: 0,
converted,
lost,
conversionRate: Math.round((converted / denom) * 1000) / 10,
+438 -33
View File
@@ -4,11 +4,16 @@ import { RouterLink, useRouter } from 'vue-router';
import axios from 'axios';
import {
Alert as AAlert,
Avatar as AAvatar,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Dropdown as ADropdown,
Empty as AEmpty,
Flex as AFlex,
Input as AInput,
Menu as AMenu,
MenuItem as AMenuItem,
Modal as AModal,
Space as ASpace,
Spin as ASpin,
Tag as ATag,
@@ -18,25 +23,43 @@ import {
import {
CalendarOutlined,
HolderOutlined,
MoreOutlined,
PhoneOutlined,
PlusOutlined,
ReloadOutlined,
UnorderedListOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import type { LeadBoardColumnDto, LeadDto } from '@mento/shared';
import { canManageSettings } from '@/lib/roles';
import { useAuthStore } from '@/stores/auth';
import type { LeadBoardColumnDto, LeadDto, LeadStageDto } from '@mento/shared';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const router = useRouter();
const auth = useAuthStore();
const canManage = computed(() => canManageSettings(auth.user?.role));
const GAP = 16;
const COL_WIDTH = 280;
const loading = ref(false);
const moving = ref(false);
const stageSaving = ref(false);
const error = ref('');
const stageError = ref('');
const columns = ref<LeadBoardColumnDto[]>([]);
const stages = ref<LeadStageDto[]>([]);
const renameTarget = ref<LeadStageDto | null>(null);
const renameName = ref('');
const addOpen = ref(false);
const addName = ref('');
const addConverted = ref(false);
const addLost = ref(false);
const addUnqualified = ref(false);
const deleteTarget = ref<LeadStageDto | null>(null);
const moveConfirm = ref<{ stageIds: string[]; message: string } | null>(null);
const draggingId = ref<string | null>(null);
const dropStageId = ref<string | null>(null);
/** بعد از درگ، کلیک باز شدن جزئیات را یک فریم نادیده بگیر */
@@ -137,14 +160,6 @@ const cardBodyStyle = computed(() => ({
margin: 0,
}));
function leadInitials(lead: LeadDto) {
const raw = (lead.contactName || lead.title || '?').trim();
const parts = raw.split(/\s+/).filter(Boolean);
if (!parts.length) return '؟';
if (parts.length === 1) return parts[0]!.slice(0, 1);
return `${parts[0]!.slice(0, 1)}${parts[parts.length - 1]!.slice(0, 1)}`;
}
function leadSubtitle(lead: LeadDto) {
return [lead.contactName, lead.city].filter(Boolean).join(' · ') || '';
}
@@ -158,22 +173,288 @@ function formatDate(value: string | null) {
}
}
function apiMessage(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
if (Array.isArray(msg)) return msg[0] ?? fallback;
if (typeof msg === 'string' && msg) return msg;
if (!e.response) return 'اتصال به API برقرار نشد.';
}
return fallback;
}
function isOpenStage(stage: Pick<LeadStageDto, 'isConverted' | 'isLost' | 'isUnqualified'>) {
return !stage.isConverted && !stage.isLost && !stage.isUnqualified;
}
function entryStage(list: LeadStageDto[]) {
return list.find(isOpenStage) ?? null;
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<LeadBoardColumnDto[]>('/leads/board');
columns.value = data;
const [board, stageRows] = await Promise.all([
api.get<LeadBoardColumnDto[]>('/leads/board'),
api.get<LeadStageDto[]>('/leads/stages'),
]);
columns.value = board.data;
stages.value = stageRows.data;
} catch (e) {
error.value =
axios.isAxiosError(e) && !e.response
? 'اتصال به API برقرار نشد.'
: 'بارگذاری مسیر لیدها ناموفق بود.';
error.value = apiMessage(e, 'بارگذاری مسیر لیدها ناموفق بود.');
} finally {
loading.value = false;
}
}
function visibleIndex(stageId: string) {
return stages.value.filter((stage) => stage.showOnBoard).findIndex((stage) => stage.id === stageId);
}
function plannedOrder(stageId: string, dir: -1 | 1) {
const visible = stages.value.filter((stage) => stage.showOnBoard);
const from = visible.findIndex((stage) => stage.id === stageId);
const to = from + dir;
if (from < 0 || to < 0 || to >= visible.length) return null;
const nextVisible = visible.slice();
const [item] = nextVisible.splice(from, 1);
if (!item) return null;
nextVisible.splice(to, 0, item);
const queue = nextVisible.slice();
return stages.value.map((stage) => (stage.showOnBoard ? queue.shift()!.id : stage.id));
}
function entryIn(ids: string[]) {
const byId = new Map(stages.value.map((stage) => [stage.id, stage]));
for (const id of ids) {
const stage = byId.get(id);
if (stage && isOpenStage(stage)) return stage;
}
return null;
}
async function persistOrder(stageIds: string[]) {
stageSaving.value = true;
error.value = '';
try {
await api.patch('/leads/stages/reorder', { stageIds });
moveConfirm.value = null;
await load();
} catch (e) {
error.value = apiMessage(e, 'جابه‌جایی ستون ناموفق بود.');
} finally {
stageSaving.value = false;
}
}
function requestMove(stageId: string, dir: -1 | 1) {
const next = plannedOrder(stageId, dir);
if (!next) return;
const before = entryStage(stages.value);
const after = entryIn(next);
if (before && after && before.id !== after.id) {
moveConfirm.value = {
stageIds: next,
message: `با این جابه‌جایی، لیدهای تازهٔ فرم و ورود از اکسل به‌جای «${before.name}» در ستون «${after.name}» ثبت می‌شوند. لیدهای فعلی سر جایشان می‌مانند.`,
};
return;
}
void persistOrder(next);
}
function nameTaken(name: string, exceptId?: string) {
const trimmed = name.trim();
return stages.value.some((stage) => stage.id !== exceptId && stage.name === trimmed);
}
function openRename(stage: LeadStageDto) {
stageError.value = '';
renameName.value = stage.name;
renameTarget.value = stage;
}
function openAdd() {
stageError.value = '';
addName.value = '';
addConverted.value = false;
addLost.value = false;
addUnqualified.value = false;
addOpen.value = true;
}
function openDelete(stage: LeadStageDto) {
stageError.value = '';
deleteTarget.value = stage;
}
function onColumnMenu(stageId: string, key: string) {
const stage = stages.value.find((item) => item.id === stageId);
if (!stage) return;
if (key === 'rename') openRename(stage);
else if (key === 'right') requestMove(stageId, -1);
else if (key === 'left') requestMove(stageId, 1);
else if (key === 'delete') openDelete(stage);
}
const renameNotes = computed(() => {
const stage = renameTarget.value;
if (!stage) return [];
const lines = [
'اسم جدید در فهرست لیدها، صفحهٔ جزئیات، فرم‌ساز و فیلتر پیامک هم دیده می‌شود. لیدهای این ستون سر جایشان می‌مانند.',
];
if (entryStage(stages.value)?.id === stage.id) {
lines.push('شمارش لیدهای تازه به اسم ستون وابسته نیست و با این تغییر نام به‌هم نمی‌ریزد.');
}
if (stage.isConverted) lines.push('علامت تبدیل‌شده روی همین ستون می‌ماند.');
if (stage.isLost) lines.push('علامت از دست‌رفته روی همین ستون می‌ماند و آمار همان را می‌شمارد.');
if (stage.isUnqualified) lines.push('علامت نامرتبط روی همین ستون می‌ماند.');
return lines;
});
const addNotes = computed(() => {
const lines = [
'ستون در انتهای مسیر اضافه می‌شود. لیدهای ستون‌های دیگر جابه‌جا نمی‌شوند.',
];
const flags = [addConverted.value, addLost.value, addUnqualified.value].filter(Boolean).length;
if (flags > 1) {
lines.push(
'یک ستون را با یک علامت بسازید. اگر تبدیل‌شده، از دست‌رفته و نامرتبط با هم باشند، فرم و آمار هر کدام رفتار جدا دارند.',
);
} else if (!flags) {
lines.push('بدون علامت، این ستون مرحلهٔ عادی است و مقصد تبدیل به مشتری یا آمار از دست‌رفته نمی‌شود.');
}
if (addConverted.value && stages.value.some((stage) => stage.isConverted)) {
lines.push(
'ستون تبدیل‌شدهٔ دیگری هم هست. تبدیل به مشتری به اولین ستون تبدیل‌شده در ترتیب مسیر می‌رود.',
);
}
if (addLost.value && stages.value.some((stage) => stage.isLost)) {
lines.push('ستون از دست‌رفتهٔ دیگری هم هست. آمار، لیدهای هر دو ستون را می‌شمارد.');
}
return lines;
});
const deleteInfo = computed(() => {
const stage = deleteTarget.value;
if (!stage) return { blocked: '', lines: [] as string[] };
const total = columns.value.find((col) => col.stage.id === stage.id)?.total ?? stage.leadCount ?? 0;
if (total > 0) {
return {
blocked: `ستون «${stage.name}» ${toFaDigits(total)} لید دارد و حذف نمی‌شود. اول لیدها را به ستون دیگری ببرید.`,
lines: [] as string[],
};
}
const others = stages.value.filter((item) => item.id !== stage.id);
const lines = [
'این ستون از فهرست لیدها، جزئیات، فرم‌ساز و فیلتر پیامک هم برداشته می‌شود.',
];
if (stage.isConverted && !others.some((item) => item.isConverted)) {
lines.push(
'این تنها ستون تبدیل‌شده است. بعد از حذف، تبدیل به مشتری هنوز انجام می‌شود ولی لید به ستون تبدیل‌شده منتقل نمی‌شود.',
);
}
if (stage.isLost && !others.some((item) => item.isLost)) {
lines.push('این تنها ستون از دست‌رفته است. بعد از حذف، آمار لیدهای از دست‌رفته دیگر ستونی برای شمردن ندارد.');
}
if (stage.isUnqualified && !others.some((item) => item.isUnqualified)) {
lines.push('این تنها ستون نامرتبط است. بعد از حذف، مسیر دیگر ستون مشخصی برای لید نامرتبط ندارد.');
}
if (entryStage(stages.value)?.id === stage.id) {
const next = others.find(isOpenStage);
lines.push(
next
? `لیدهای تازهٔ فرم و ورود از اکسل به‌جای این ستون در «${next.name}» ثبت می‌شوند.`
: 'ستون باز دیگری نمی‌ماند. لید تازهٔ فرم در اولین ستون باقی‌مانده ثبت می‌شود.',
);
}
lines.push('اگر فرمی این ستون را مرحلهٔ اولیه کرده باشد، لید تازه به اولین ستون باز می‌رود.');
return { blocked: '', lines };
});
async function saveRename() {
const stage = renameTarget.value;
const name = renameName.value.trim();
if (!stage || stageSaving.value) return;
if (name.length < 1) {
stageError.value = 'نام ستون لازم است.';
return;
}
if (name.length > 80) {
stageError.value = 'نام ستون حداکثر ۸۰ کاراکتر است.';
return;
}
if (nameTaken(name, stage.id)) {
stageError.value = 'ستونی با این نام وجود دارد.';
return;
}
if (name === stage.name) {
renameTarget.value = null;
return;
}
stageSaving.value = true;
stageError.value = '';
try {
await api.patch(`/leads/stages/${stage.id}`, { name });
renameTarget.value = null;
await load();
} catch (e) {
stageError.value = apiMessage(e, 'تغییر نام ستون ناموفق بود.');
} finally {
stageSaving.value = false;
}
}
async function saveAdd() {
const name = addName.value.trim();
if (stageSaving.value) return;
if (!name) {
stageError.value = 'نام ستون لازم است.';
return;
}
if (name.length > 80) {
stageError.value = 'نام ستون حداکثر ۸۰ کاراکتر است.';
return;
}
if (nameTaken(name)) {
stageError.value = 'ستونی با این نام وجود دارد.';
return;
}
stageSaving.value = true;
stageError.value = '';
try {
await api.post('/leads/stages', {
name,
showOnBoard: true,
isConverted: addConverted.value,
isLost: addLost.value,
isUnqualified: addUnqualified.value,
});
addOpen.value = false;
await load();
} catch (e) {
stageError.value = apiMessage(e, 'افزودن ستون ناموفق بود.');
} finally {
stageSaving.value = false;
}
}
async function confirmDelete() {
const stage = deleteTarget.value;
if (!stage || deleteInfo.value.blocked || stageSaving.value) return;
stageSaving.value = true;
stageError.value = '';
try {
await api.delete(`/leads/stages/${stage.id}`);
deleteTarget.value = null;
await load();
} catch (e) {
stageError.value = apiMessage(e, 'حذف ستون ناموفق بود.');
} finally {
stageSaving.value = false;
}
}
function openLead(lead: LeadDto) {
if (suppressCardClick || draggingId.value || moving.value) return;
void router.push({ name: 'lead-detail', params: { id: lead.id } });
@@ -223,11 +504,16 @@ async function onDrop(stageId: string, event: DragEvent) {
columns.value = columns.value.map((c) => {
if (c.stage.id === fromCol.stage.id) {
return { ...c, leads: c.leads.filter((l) => l.id !== leadId) };
return {
...c,
total: Math.max(0, c.total - 1),
leads: c.leads.filter((l) => l.id !== leadId),
};
}
if (c.stage.id === stageId) {
return {
...c,
total: c.total + 1,
leads: [{ ...lead, stageId, stageName: c.stage.name }, ...c.leads],
};
}
@@ -257,7 +543,7 @@ onMounted(load);
مسیر لیدها
</a-typography-title>
<a-typography-text type="secondary">
کشیدن و رها کردن برای تغییر وضعیت
کشیدن کارت برای تغییر وضعیت. از منوی هر ستون می‌توانید نام، ترتیب و حذف را عوض کنید.
</a-typography-text>
</div>
<a-space :size="GAP">
@@ -298,9 +584,14 @@ onMounted(load);
<a-empty
v-if="!loading && !columns.length"
description="مرحله‌ای برای نمایش در مسیر لیدها نیست. در تنظیمات مراحل لید، «نمایش روی مسیر» را فعال کنید."
description="مرحله‌ای برای نمایش در مسیر لیدها نیست."
:style="{ margin: 'auto' }"
/>
>
<a-button v-if="canManage" type="primary" @click="openAdd">
<template #icon><PlusOutlined /></template>
افزودن ستون
</a-button>
</a-empty>
<div
v-else-if="columns.length"
@@ -328,9 +619,31 @@ onMounted(load);
/>
<a-typography-text strong>{{ col.stage.name }}</a-typography-text>
</a-space>
<a-typography-text type="secondary">
{{ toFaDigits(col.leads.length) }}
</a-typography-text>
<a-flex align="center" :gap="4">
<a-typography-text type="secondary">
{{ toFaDigits(col.total) }}
</a-typography-text>
<a-dropdown v-if="canManage" :trigger="['click']">
<a-button type="text" aria-label="منوی ستون" @click.stop>
<template #icon><MoreOutlined /></template>
</a-button>
<template #overlay>
<a-menu @click="({ key }) => onColumnMenu(col.stage.id, String(key))">
<a-menu-item key="rename">تغییر نام</a-menu-item>
<a-menu-item key="right" :disabled="visibleIndex(col.stage.id) <= 0">
انتقال به راست
</a-menu-item>
<a-menu-item
key="left"
:disabled="visibleIndex(col.stage.id) < 0 || visibleIndex(col.stage.id) >= stages.filter((stage) => stage.showOnBoard).length - 1"
>
انتقال به چپ
</a-menu-item>
<a-menu-item key="delete" danger>حذف ستون</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-flex>
</div>
<div :style="columnBodyStyle">
@@ -376,16 +689,6 @@ onMounted(load);
>
<HolderOutlined />
</span>
<a-avatar
:style="{
background: token.colorPrimaryBg,
color: token.colorPrimary,
flexShrink: 0,
fontWeight: 700,
}"
>
{{ leadInitials(lead) }}
</a-avatar>
<div :style="{ flex: 1, minWidth: 0 }">
<a-typography-text
strong
@@ -501,8 +804,110 @@ onMounted(load);
</a-typography-text>
</div>
</div>
<a-flex
v-if="canManage"
vertical
justify="center"
align="center"
:gap="8"
:style="{
flex: `0 0 ${COL_WIDTH}px`,
width: `${COL_WIDTH}px`,
alignSelf: 'stretch',
border: `1px dashed ${token.colorBorder}`,
borderRadius: `${token.borderRadiusLG}px`,
background: token.colorFillAlter,
}"
>
<a-button type="primary" :disabled="stageSaving" @click="openAdd">
<template #icon><PlusOutlined /></template>
افزودن ستون
</a-button>
</a-flex>
</div>
</div>
<a-modal
:open="!!renameTarget"
title="تغییر نام ستون"
ok-text="ذخیره"
cancel-text="انصراف"
:confirm-loading="stageSaving"
@ok="saveRename"
@cancel="renameTarget = null"
>
<a-flex vertical :gap="12">
<a-alert
v-for="line in renameNotes"
:key="line"
type="info"
show-icon
:message="line"
/>
<a-alert v-if="stageError" type="error" show-icon :message="stageError" />
<a-input v-model:value="renameName" :maxlength="80" placeholder="نام ستون" @press-enter="saveRename" />
</a-flex>
</a-modal>
<a-modal
:open="addOpen"
title="افزودن ستون"
ok-text="افزودن"
cancel-text="انصراف"
:confirm-loading="stageSaving"
@ok="saveAdd"
@cancel="addOpen = false"
>
<a-flex vertical :gap="12">
<a-alert
v-for="(line, index) in addNotes"
:key="index"
:type="index === 0 ? 'info' : 'warning'"
show-icon
:message="line"
/>
<a-alert v-if="stageError" type="error" show-icon :message="stageError" />
<a-input v-model:value="addName" :maxlength="80" placeholder="مثلاً تماس اولیه" @press-enter="saveAdd" />
<a-checkbox v-model:checked="addConverted">علامت تبدیل‌شده</a-checkbox>
<a-checkbox v-model:checked="addLost">علامت از دست‌رفته</a-checkbox>
<a-checkbox v-model:checked="addUnqualified">علامت نامرتبط</a-checkbox>
</a-flex>
</a-modal>
<a-modal
:open="!!deleteTarget"
:title="deleteInfo.blocked ? 'حذف ستون ممکن نیست' : 'حذف ستون'"
:ok-text="deleteInfo.blocked ? 'متوجه شدم' : 'حذف ستون'"
:ok-button-props="deleteInfo.blocked ? undefined : { danger: true }"
cancel-text="انصراف"
:confirm-loading="stageSaving"
@ok="deleteInfo.blocked ? (deleteTarget = null) : confirmDelete()"
@cancel="deleteTarget = null"
>
<a-flex vertical :gap="12">
<a-alert v-if="deleteInfo.blocked" type="error" show-icon :message="deleteInfo.blocked" />
<a-alert
v-for="line in deleteInfo.lines"
:key="line"
type="warning"
show-icon
:message="line"
/>
<a-alert v-if="stageError" type="error" show-icon :message="stageError" />
</a-flex>
</a-modal>
<a-modal
:open="!!moveConfirm"
title="جابه‌جایی ستون"
ok-text="جابه‌جا شود"
cancel-text="انصراف"
:confirm-loading="stageSaving"
@ok="moveConfirm && persistOrder(moveConfirm.stageIds)"
@cancel="moveConfirm = null"
>
<a-alert v-if="moveConfirm" type="warning" show-icon :message="moveConfirm.message" />
</a-modal>
</div>
</template>