Files
CRM_Montzar/apps/web/src/views/LeadBoardView.vue
T
MahdiandCursor 0d5e1034e5 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>
2026-10-29 17:23:40 +03:30

933 lines
30 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 } from 'vue';
import { RouterLink, useRouter } from 'vue-router';
import axios from 'axios';
import {
Alert as AAlert,
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,
Typography,
theme,
} from 'ant-design-vue';
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 { 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);
/** بعد از درگ، کلیک باز شدن جزئیات را یک فریم نادیده بگیر */
let suppressCardClick = false;
/**
* ارتفاع کامل زیر topbar شل.
* height:0 + flex:1 روی فرزند، زنجیرهٔ کشش را اجباری می‌کند.
*/
const pageStyle = computed(() => ({
boxSizing: 'border-box' as const,
display: 'flex',
flexDirection: 'column' as const,
gap: `${GAP}px`,
height: 'calc(100dvh - 120px)',
maxHeight: 'calc(100dvh - 120px)',
minHeight: '420px',
minWidth: 0,
}));
const boardShellStyle = computed(() => ({
boxSizing: 'border-box' as const,
flex: '1 1 auto',
height: 0,
minHeight: 0,
display: 'flex',
flexDirection: 'column' as const,
padding: `${GAP}px`,
background: token.value.colorBgContainer,
border: `1px solid ${token.value.colorBorderSecondary}`,
borderRadius: `${token.value.borderRadiusLG}px`,
position: 'relative' as const,
overflow: 'hidden',
}));
const boardTrackStyle = computed(() => ({
boxSizing: 'border-box' as const,
flex: '1 1 auto',
height: 0,
minHeight: 0,
display: 'flex',
flexDirection: 'row' as const,
alignItems: 'stretch',
gap: `${GAP}px`,
width: '100%',
overflowX: 'auto' as const,
overflowY: 'hidden' as const,
paddingBottom: '4px',
}));
function columnStyle(stageId: string) {
const active = dropStageId.value === stageId;
return {
boxSizing: 'border-box' as const,
flex: `0 0 ${COL_WIDTH}px`,
width: `${COL_WIDTH}px`,
alignSelf: 'stretch',
display: 'flex',
flexDirection: 'column' as const,
minHeight: 0,
background: token.value.colorFillAlter,
border: `1px solid ${
active ? token.value.colorPrimary : token.value.colorBorderSecondary
}`,
borderRadius: `${token.value.borderRadiusLG}px`,
boxShadow: active ? `0 0 0 2px ${token.value.colorPrimaryBg}` : undefined,
overflow: 'hidden',
};
}
const columnHeadStyle = computed(() => ({
boxSizing: 'border-box' as const,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: `${GAP}px`,
padding: `${GAP}px`,
borderBottom: `1px solid ${token.value.colorBorderSecondary}`,
background: token.value.colorBgContainer,
}));
const columnBodyStyle = computed(() => ({
boxSizing: 'border-box' as const,
flex: '1 1 auto',
height: 0,
minHeight: 0,
overflowY: 'auto' as const,
display: 'flex',
flexDirection: 'column' as const,
gap: '12px',
padding: '12px',
margin: 0,
}));
const cardBodyStyle = computed(() => ({
padding: '12px 14px',
margin: 0,
}));
function leadSubtitle(lead: LeadDto) {
return [lead.contactName, lead.city].filter(Boolean).join(' · ') || '';
}
function formatDate(value: string | null) {
if (!value) return '';
try {
return formatJalaliNumeric(new Date(value));
} catch {
return value;
}
}
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 [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 = 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 } });
}
function onDragStart(lead: LeadDto, event: DragEvent) {
suppressCardClick = true;
draggingId.value = lead.id;
event.dataTransfer?.setData('text/plain', lead.id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
}
function onDragEnd() {
draggingId.value = null;
dropStageId.value = null;
window.setTimeout(() => {
suppressCardClick = false;
}, 0);
}
function onDragOver(stageId: string, event: DragEvent) {
event.preventDefault();
dropStageId.value = stageId;
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
}
async function onDrop(stageId: string, event: DragEvent) {
event.preventDefault();
const leadId =
event.dataTransfer?.getData('text/plain') || draggingId.value || '';
draggingId.value = null;
dropStageId.value = null;
if (!leadId) return;
const fromCol = columns.value.find((c) =>
c.leads.some((l) => l.id === leadId),
);
if (!fromCol || fromCol.stage.id === stageId) return;
const lead = fromCol.leads.find((l) => l.id === leadId);
if (!lead) return;
const prev = columns.value.map((c) => ({
...c,
leads: [...c.leads],
}));
columns.value = columns.value.map((c) => {
if (c.stage.id === fromCol.stage.id) {
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],
};
}
return c;
});
moving.value = true;
error.value = '';
try {
await api.patch(`/leads/${leadId}/move`, { stageId });
} catch {
columns.value = prev;
error.value = 'جابه‌جایی لید ناموفق بود.';
} finally {
moving.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="lead-board-page" :style="pageStyle">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="GAP">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">
مسیر لیدها
</a-typography-title>
<a-typography-text type="secondary">
کشیدن کارت برای تغییر وضعیت. از منوی هر ستون می‌توانید نام، ترتیب و حذف را عوض کنید.
</a-typography-text>
</div>
<a-space :size="GAP">
<RouterLink :to="{ name: 'leads' }" custom v-slot="{ navigate }">
<a-button @click="navigate">
<template #icon><UnorderedListOutlined /></template>
فهرست لیدها
</a-button>
</RouterLink>
<a-button
:disabled="loading || moving"
:loading="loading"
@click="load"
>
<template #icon><ReloadOutlined /></template>
بروزرسانی
</a-button>
</a-space>
</a-flex>
<a-alert v-if="error" type="error" show-icon :message="error" />
<div :style="boardShellStyle">
<div
v-if="loading || moving"
:style="{
position: 'absolute',
inset: 0,
zIndex: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: token.colorBgMask,
}"
>
<a-spin />
</div>
<a-empty
v-if="!loading && !columns.length"
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"
class="lead-board-track"
:style="boardTrackStyle"
>
<div
v-for="col in columns"
:key="col.stage.id"
:style="columnStyle(col.stage.id)"
@dragover="onDragOver(col.stage.id, $event)"
@drop="onDrop(col.stage.id, $event)"
>
<div :style="columnHeadStyle">
<a-space :size="8">
<span
:style="{
width: '8px',
height: '8px',
borderRadius: '50%',
background: col.stage.color || token.colorPrimary,
display: 'inline-block',
flexShrink: 0,
}"
/>
<a-typography-text strong>{{ col.stage.name }}</a-typography-text>
</a-space>
<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">
<a-card
v-for="lead in col.leads"
:key="lead.id"
hoverable
:bordered="true"
:body-style="cardBodyStyle"
:style="{
cursor: 'pointer',
opacity: draggingId === lead.id ? 0.45 : 1,
flexShrink: 0,
margin: 0,
background: token.colorBgContainer,
boxShadow: token.boxShadow,
}"
@click="openLead(lead)"
>
<a-flex vertical :gap="10" :style="{ width: '100%', minWidth: 0 }">
<a-flex :gap="10" align="center" :style="{ minWidth: 0 }">
<span
role="button"
tabindex="0"
aria-label="جابه‌جایی لید"
title="برای جابه‌جایی بکشید"
draggable="true"
:style="{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
flexShrink: 0,
borderRadius: `${token.borderRadiusSM}px`,
color: token.colorTextTertiary,
cursor: 'grab',
background: token.colorFillQuaternary,
}"
@click.stop
@dragstart.stop="onDragStart(lead, $event)"
@dragend.stop="onDragEnd"
>
<HolderOutlined />
</span>
<div :style="{ flex: 1, minWidth: 0 }">
<a-typography-text
strong
ellipsis
:style="{ display: 'block', margin: 0, lineHeight: 1.35 }"
:title="lead.title"
>
{{ lead.title }}
</a-typography-text>
<a-typography-text
v-if="leadSubtitle(lead)"
type="secondary"
ellipsis
:style="{
display: 'block',
margin: 0,
marginTop: '2px',
fontSize: `${token.fontSizeSM}px`,
lineHeight: 1.35,
}"
:title="leadSubtitle(lead)"
>
{{ leadSubtitle(lead) }}
</a-typography-text>
</div>
</a-flex>
<a-flex
justify="space-between"
align="center"
:gap="8"
:style="{ minWidth: 0 }"
>
<a-flex
v-if="lead.mobile"
align="center"
:gap="6"
dir="ltr"
:style="{ minWidth: 0 }"
>
<PhoneOutlined
:style="{
color: token.colorPrimary,
fontSize: `${token.fontSizeSM}px`,
}"
/>
<a-typography-text
:style="{
margin: 0,
fontSize: `${token.fontSizeSM}px`,
fontVariantNumeric: 'tabular-nums',
color: token.colorText,
}"
>
{{ lead.mobile }}
</a-typography-text>
</a-flex>
<a-tag
v-if="lead.assigneeName"
:bordered="false"
:style="{
margin: 0,
maxWidth: '46%',
overflow: 'hidden',
textOverflow: 'ellipsis',
background: token.colorFillSecondary,
}"
:title="lead.assigneeName"
>
{{ lead.assigneeName }}
</a-tag>
</a-flex>
<a-flex
v-if="lead.nextFollowUpAt"
align="center"
:gap="6"
:style="{
paddingTop: '8px',
borderTop: `1px solid ${token.colorSplit}`,
}"
>
<CalendarOutlined
:style="{
color: token.colorTextSecondary,
fontSize: `${token.fontSizeSM}px`,
}"
/>
<a-typography-text
type="secondary"
:style="{
margin: 0,
fontSize: `${token.fontSizeSM}px`,
}"
>
پیگیری {{ formatDate(lead.nextFollowUpAt) }}
</a-typography-text>
</a-flex>
</a-flex>
</a-card>
<a-typography-text
v-if="!col.leads.length"
type="secondary"
:style="{
textAlign: 'center',
display: 'block',
margin: 'auto',
padding: `${GAP}px`,
}"
>
خالی
</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>
<style scoped>
/* فقط ارتفاع/اسکرول ساختاری — بدون رنگ/ری‌اسکین Ant */
.lead-board-page {
width: 100%;
}
.lead-board-track {
scrollbar-width: thin;
}
.lead-board-track::-webkit-scrollbar {
height: 8px;
}
.lead-board-track::-webkit-scrollbar-thumb {
border-radius: 999px;
background: color-mix(in srgb, currentColor 28%, transparent);
}
</style>