Scroll the column while a card is held near its edge and show a light gap where the card will land. Co-authored-by: Cursor <cursoragent@cursor.com>
994 lines
33 KiB
Vue
994 lines
33 KiB
Vue
<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 { dragScrollColumn, pointerDropIndex, stopDragScroll } from '@/lib/pipeline-board';
|
||
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 insertAt = ref<number | null>(null);
|
||
const bodyEls = new Map<string, HTMLElement>();
|
||
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 setBodyEl(stageId: string, el: unknown) {
|
||
if (el instanceof HTMLElement) bodyEls.set(stageId, el);
|
||
else bodyEls.delete(stageId);
|
||
}
|
||
|
||
function clearDragUi() {
|
||
insertAt.value = null;
|
||
stopDragScroll();
|
||
}
|
||
|
||
function slotStyle(active: boolean, collapseGap = false) {
|
||
return {
|
||
height: active ? '36px' : '0px',
|
||
marginTop: !active && collapseGap ? '-12px' : '0',
|
||
borderRadius: `${token.value.borderRadius}px`,
|
||
background: active ? token.value.colorPrimaryBg : 'transparent',
|
||
outline: active ? `1px dashed ${token.value.colorPrimary}` : 'none',
|
||
outlineOffset: '-1px',
|
||
transition: 'height 180ms ease, margin-top 180ms ease, background 180ms ease',
|
||
overflow: 'hidden',
|
||
flexShrink: 0,
|
||
};
|
||
}
|
||
|
||
function onDragEnd() {
|
||
draggingId.value = null;
|
||
dropStageId.value = null;
|
||
clearDragUi();
|
||
window.setTimeout(() => {
|
||
suppressCardClick = false;
|
||
}, 0);
|
||
}
|
||
|
||
function onDragOver(stageId: string, event: DragEvent, index?: number) {
|
||
event.preventDefault();
|
||
dropStageId.value = stageId;
|
||
dragScrollColumn(bodyEls.get(stageId) ?? null, event.clientY);
|
||
if (index != null) insertAt.value = index;
|
||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||
}
|
||
|
||
async function onDrop(stageId: string, event: DragEvent, index?: number) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const leadId =
|
||
event.dataTransfer?.getData('text/plain') || draggingId.value || '';
|
||
const at = index ?? insertAt.value ?? undefined;
|
||
draggingId.value = null;
|
||
dropStageId.value = null;
|
||
clearDragUi();
|
||
if (!leadId || moving.value) return;
|
||
|
||
const fromCol = columns.value.find((c) =>
|
||
c.leads.some((l) => l.id === leadId),
|
||
);
|
||
const target = columns.value.find((c) => c.stage.id === stageId);
|
||
if (!fromCol || !target) return;
|
||
const fromIndex = fromCol.leads.findIndex((l) => l.id === leadId);
|
||
const lead = fromCol.leads[fromIndex];
|
||
if (!lead) return;
|
||
|
||
const same = fromCol.stage.id === stageId;
|
||
if (same && at == null) return;
|
||
const without = (same ? fromCol.leads : target.leads).filter((l) => l.id !== leadId);
|
||
let insertIndex = at == null || at < 0 ? 0 : at;
|
||
if (same && at != null && at > fromIndex) insertIndex -= 1;
|
||
insertIndex = Math.max(0, Math.min(insertIndex, without.length));
|
||
if (same && insertIndex === fromIndex) return;
|
||
const nextLeads = [...without];
|
||
nextLeads.splice(insertIndex, 0, { ...lead, stageId, stageName: target.stage.name });
|
||
|
||
const prev = columns.value.map((c) => ({
|
||
...c,
|
||
leads: [...c.leads],
|
||
}));
|
||
|
||
columns.value = columns.value.map((c) => {
|
||
if (same && c.stage.id === stageId) return { ...c, leads: nextLeads };
|
||
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: nextLeads };
|
||
}
|
||
return c;
|
||
});
|
||
|
||
moving.value = true;
|
||
error.value = '';
|
||
try {
|
||
await api.patch(`/leads/${leadId}/move`, { stageId, position: insertIndex });
|
||
} 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
|
||
:ref="(el) => setBodyEl(col.stage.id, el)"
|
||
:style="columnBodyStyle"
|
||
>
|
||
<div
|
||
v-for="(lead, index) in col.leads"
|
||
:key="lead.id"
|
||
@dragover.prevent="onDragOver(col.stage.id, $event, pointerDropIndex(index, $event))"
|
||
@drop.stop="onDrop(col.stage.id, $event, pointerDropIndex(index, $event))"
|
||
>
|
||
<div
|
||
:style="slotStyle(dropStageId === col.stage.id && insertAt === index)"
|
||
@dragover.stop.prevent="onDragOver(col.stage.id, $event, index)"
|
||
@drop.stop.prevent="onDrop(col.stage.id, $event, index)"
|
||
/>
|
||
<a-card
|
||
hoverable
|
||
:bordered="true"
|
||
:body-style="cardBodyStyle"
|
||
:style="{
|
||
cursor: 'pointer',
|
||
opacity: draggingId === lead.id ? 0.4 : 1,
|
||
transform: draggingId === lead.id ? 'scale(0.98)' : 'none',
|
||
transition: 'opacity 180ms ease, transform 180ms ease, box-shadow 180ms ease',
|
||
flexShrink: 0,
|
||
margin: 0,
|
||
background: token.colorBgContainer,
|
||
boxShadow: draggingId === lead.id ? token.boxShadowSecondary : 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>
|
||
</div>
|
||
<div
|
||
:style="slotStyle(dropStageId === col.stage.id && insertAt === col.leads.length, true)"
|
||
@dragover.stop.prevent="onDragOver(col.stage.id, $event, col.leads.length)"
|
||
@drop.stop.prevent="onDrop(col.stage.id, $event, col.leads.length)"
|
||
/>
|
||
|
||
<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>
|