2026-09-27 17:05:11 +03:30
< script setup lang = "ts" >
2026-10-18 19:39:59 +03:30
import { computed , onMounted , ref } from 'vue' ;
import { RouterLink , useRouter } from 'vue-router' ;
2026-09-27 17:05:11 +03:30
import axios from 'axios' ;
2026-10-18 19:39:59 +03:30
import {
Alert as AAlert ,
Button as AButton ,
Card as ACard ,
2026-10-29 17:23:40 +03:30
Checkbox as ACheckbox ,
Dropdown as ADropdown ,
2026-10-18 19:39:59 +03:30
Empty as AEmpty ,
Flex as AFlex ,
2026-10-29 17:23:40 +03:30
Input as AInput ,
Menu as AMenu ,
MenuItem as AMenuItem ,
Modal as AModal ,
2026-10-18 19:39:59 +03:30
Space as ASpace ,
Spin as ASpin ,
Tag as ATag ,
Typography ,
theme ,
} from 'ant-design-vue' ;
import {
CalendarOutlined ,
HolderOutlined ,
2026-10-29 17:23:40 +03:30
MoreOutlined ,
2026-10-18 19:39:59 +03:30
PhoneOutlined ,
2026-10-29 17:23:40 +03:30
PlusOutlined ,
2026-10-18 19:39:59 +03:30
ReloadOutlined ,
UnorderedListOutlined ,
} from '@ant-design/icons-vue' ;
2026-09-27 17:05:11 +03:30
import { api } from '@/lib/api' ;
import { formatJalaliNumeric , toFaDigits } from '@/lib/jalali' ;
2026-10-29 17:23:40 +03:30
import { canManageSettings } from '@/lib/roles' ;
2026-11-04 20:05:50 +03:30
import { dragScrollColumn , pointerDropIndex , stopDragScroll } from '@/lib/pipeline-board' ;
2026-10-29 17:23:40 +03:30
import { useAuthStore } from '@/stores/auth' ;
import type { LeadBoardColumnDto , LeadDto , LeadStageDto } from '@mento/shared' ;
2026-09-27 17:05:11 +03:30
2026-10-18 19:39:59 +03:30
const { Title : ATypographyTitle , Text : ATypographyText } = Typography ;
const { token } = theme . useToken ();
const router = useRouter ();
2026-10-29 17:23:40 +03:30
const auth = useAuthStore ();
const canManage = computed (() => canManageSettings ( auth . user ? . role ));
2026-10-18 19:39:59 +03:30
const GAP = 16 ;
const COL_WIDTH = 280 ;
2026-09-27 17:05:11 +03:30
const loading = ref ( false );
const moving = ref ( false );
2026-10-29 17:23:40 +03:30
const stageSaving = ref ( false );
2026-09-27 17:05:11 +03:30
const error = ref ( '' );
2026-10-29 17:23:40 +03:30
const stageError = ref ( '' );
2026-09-27 17:05:11 +03:30
const columns = ref < LeadBoardColumnDto [] >([]);
2026-10-29 17:23:40 +03:30
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 );
2026-09-27 17:05:11 +03:30
const draggingId = ref < string | null >( null );
2026-11-04 20:05:50 +03:30
const insertAt = ref < number | null >( null );
const bodyEls = new Map < string , HTMLElement >();
2026-09-27 17:05:11 +03:30
const dropStageId = ref < string | null >( null );
2026-10-18 19:39:59 +03:30
/** بعد از درگ، کلیک باز شدن جزئیات را یک فریم نادیده بگیر */
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 ( ' · ' ) || '' ;
}
2026-09-27 17:05:11 +03:30
function formatDate ( value : string | null ) {
if ( ! value ) return '' ;
try {
return formatJalaliNumeric ( new Date ( value ));
} catch {
return value ;
}
}
2026-10-29 17:23:40 +03:30
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 ;
}
2026-09-27 17:05:11 +03:30
async function load () {
loading . value = true ;
error . value = '' ;
try {
2026-10-29 17:23:40 +03:30
const [ board , stageRows ] = await Promise . all ([
api . get < LeadBoardColumnDto [] >( '/leads/board' ),
api . get < LeadStageDto [] >( '/leads/stages' ),
]);
columns . value = board . data ;
stages . value = stageRows . data ;
2026-09-27 17:05:11 +03:30
} catch ( e ) {
2026-10-29 17:23:40 +03:30
error . value = apiMessage ( e , 'بارگذاری مسیر لیدها ناموفق بود.' );
2026-09-27 17:05:11 +03:30
} finally {
loading . value = false ;
}
}
2026-10-29 17:23:40 +03:30
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 ;
}
}
2026-10-18 19:39:59 +03:30
function openLead ( lead : LeadDto ) {
if ( suppressCardClick || draggingId . value || moving . value ) return ;
void router . push ({ name : 'lead-detail' , params : { id : lead . id } });
}
2026-09-27 17:05:11 +03:30
function onDragStart ( lead : LeadDto , event : DragEvent ) {
2026-10-18 19:39:59 +03:30
suppressCardClick = true ;
2026-09-27 17:05:11 +03:30
draggingId . value = lead . id ;
event . dataTransfer ? . setData ( 'text/plain' , lead . id );
if ( event . dataTransfer ) event . dataTransfer . effectAllowed = 'move' ;
}
2026-11-04 20:05:50 +03:30
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 ,
};
}
2026-09-27 17:05:11 +03:30
function onDragEnd () {
draggingId . value = null ;
dropStageId . value = null ;
2026-11-04 20:05:50 +03:30
clearDragUi ();
2026-10-18 19:39:59 +03:30
window . setTimeout (() => {
suppressCardClick = false ;
}, 0 );
2026-09-27 17:05:11 +03:30
}
2026-11-04 20:05:50 +03:30
function onDragOver ( stageId : string , event : DragEvent , index ?: number ) {
2026-09-27 17:05:11 +03:30
event . preventDefault ();
dropStageId . value = stageId ;
2026-11-04 20:05:50 +03:30
dragScrollColumn ( bodyEls . get ( stageId ) ?? null , event . clientY );
if ( index != null ) insertAt . value = index ;
2026-09-27 17:05:11 +03:30
if ( event . dataTransfer ) event . dataTransfer . dropEffect = 'move' ;
}
2026-11-03 21:22:30 +03:30
async function onDrop ( stageId : string , event : DragEvent , index ?: number ) {
2026-09-27 17:05:11 +03:30
event . preventDefault ();
2026-11-03 21:22:30 +03:30
event . stopPropagation ();
2026-09-27 17:05:11 +03:30
const leadId =
event . dataTransfer ? . getData ( 'text/plain' ) || draggingId . value || '' ;
2026-11-04 20:05:50 +03:30
const at = index ?? insertAt . value ?? undefined ;
2026-09-27 17:05:11 +03:30
draggingId . value = null ;
dropStageId . value = null ;
2026-11-04 20:05:50 +03:30
clearDragUi ();
2026-11-03 21:22:30 +03:30
if ( ! leadId || moving . value ) return ;
2026-09-27 17:05:11 +03:30
const fromCol = columns . value . find (( c ) =>
c . leads . some (( l ) => l . id === leadId ),
);
2026-11-03 21:22:30 +03:30
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 ];
2026-09-27 17:05:11 +03:30
if ( ! lead ) return ;
2026-11-03 21:22:30 +03:30
const same = fromCol . stage . id === stageId ;
2026-11-04 20:05:50 +03:30
if ( same && at == null ) return ;
2026-11-03 21:22:30 +03:30
const without = ( same ? fromCol . leads : target . leads ). filter (( l ) => l . id !== leadId );
2026-11-04 20:05:50 +03:30
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 ;
2026-11-03 21:22:30 +03:30
const nextLeads = [... without ];
2026-11-04 20:05:50 +03:30
nextLeads . splice ( insertIndex , 0 , { ... lead , stageId , stageName : target . stage . name });
2026-11-03 21:22:30 +03:30
2026-09-27 17:05:11 +03:30
const prev = columns . value . map (( c ) => ({
... c ,
leads : [... c . leads ],
}));
columns . value = columns . value . map (( c ) => {
2026-11-03 21:22:30 +03:30
if ( same && c . stage . id === stageId ) return { ... c , leads : nextLeads };
2026-09-27 17:05:11 +03:30
if ( c . stage . id === fromCol . stage . id ) {
2026-10-29 17:23:40 +03:30
return {
... c ,
total : Math . max ( 0 , c . total - 1 ),
leads : c . leads . filter (( l ) => l . id !== leadId ),
};
2026-09-27 17:05:11 +03:30
}
if ( c . stage . id === stageId ) {
2026-11-03 21:22:30 +03:30
return { ... c , total : c . total + 1 , leads : nextLeads };
2026-09-27 17:05:11 +03:30
}
return c ;
});
moving . value = true ;
error . value = '' ;
try {
2026-11-04 20:05:50 +03:30
await api . patch ( `/leads/ ${ leadId } /move` , { stageId , position : insertIndex });
2026-09-27 17:05:11 +03:30
} catch {
columns . value = prev ;
error . value = 'جابهجایی لید ناموفق بود.' ;
} finally {
moving . value = false ;
}
}
onMounted ( load );
</ script >
< template >
2026-10-18 19:39:59 +03:30
< 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" >
2026-11-03 21:22:30 +03:30
کشیدن کارت برای تغییر ستون یا جابه جایی داخل همان ستون . از منوی هر ستون می توانید نام ، ترتیب و حذف را عوض کنید .
2026-10-18 19:39:59 +03:30
</ 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 >
2026-09-27 17:05:11 +03:30
</ RouterLink >
2026-10-18 19:39:59 +03:30
< a-button
: disabled = "loading || moving"
:loading = "loading"
@click ="load"
>
< template # icon >< ReloadOutlined /></ template >
2026-09-27 17:05:11 +03:30
بروزرسانی
2026-10-18 19:39:59 +03:30
</ a-button >
</ a-space >
</ a-flex >
2026-09-27 17:05:11 +03:30
2026-10-18 19:39:59 +03:30
< a-alert v-if = "error" type="error" show-icon :message="error" />
2026-09-27 17:05:11 +03:30
2026-10-18 19:39:59 +03:30
< div :style = "boardShellStyle" >
< div
v-if = "loading || moving"
: style = "{
position: 'absolute',
inset: 0,
zIndex: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: token.colorBgMask,
}"
2026-09-27 17:05:11 +03:30
>
2026-10-18 19:39:59 +03:30
< a-spin />
</ div >
2026-09-27 17:05:11 +03:30
2026-10-18 19:39:59 +03:30
< a-empty
v-if = "!loading && !columns.length"
2026-10-29 17:23:40 +03:30
description = "مرحلهای برای نمایش در مسیر لیدها نیست."
2026-10-18 19:39:59 +03:30
: style = "{ margin: 'auto' }"
2026-10-29 17:23:40 +03:30
>
< a-button v-if = "canManage" type="primary" @click="openAdd" >
< template # icon >< PlusOutlined /></ template >
افزودن ستون
</ a-button >
</ a-empty >
2026-10-18 19:39:59 +03:30
< 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 >
2026-10-29 17:23:40 +03:30
< 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 >
2026-10-18 19:39:59 +03:30
</ div >
2026-11-04 20:05:50 +03:30
< div
: ref = "(el) => setBodyEl(col.stage.id, el)"
:style = "columnBodyStyle"
>
2026-11-03 21:22:30 +03:30
< div
v-for = "(lead, index) in col.leads"
2026-10-18 19:39:59 +03:30
:key = "lead.id"
2026-11-04 20:05:50 +03:30
@dragover.prevent ="onDragOver(col.stage.id, $event, pointerDropIndex(index, $event))"
2026-11-03 21:22:30 +03:30
@drop.stop ="onDrop(col.stage.id, $event, pointerDropIndex(index, $event))"
>
2026-11-04 20:05:50 +03:30
< 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)"
/>
2026-11-03 21:22:30 +03:30
< a-card
2026-10-18 19:39:59 +03:30
hoverable
:bordered = "true"
:body-style = "cardBodyStyle"
: style = "{
cursor: 'pointer',
2026-11-04 20:05:50 +03:30
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',
2026-10-18 19:39:59 +03:30
flexShrink: 0,
margin: 0,
background: token.colorBgContainer,
2026-11-04 20:05:50 +03:30
boxShadow: draggingId === lead.id ? token.boxShadowSecondary : token.boxShadow,
2026-10-18 19:39:59 +03:30
}"
@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 >
2026-11-03 21:22:30 +03:30
</ div >
2026-11-04 20:05:50 +03:30
< 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)"
/>
2026-10-18 19:39:59 +03:30
< a-typography-text
v-if = "!col.leads.length"
type = "secondary"
: style = "{
textAlign: 'center',
display: 'block',
margin: 'auto',
padding: `${GAP}px`,
}"
>
خالی
</ a-typography-text >
</ div >
2026-09-27 17:05:11 +03:30
</ div >
2026-10-29 17:23:40 +03:30
< 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 >
2026-10-18 19:39:59 +03:30
</ div >
2026-09-27 17:05:11 +03:30
</ div >
2026-10-29 17:23:40 +03:30
< 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 >
2026-10-18 19:39:59 +03:30
</ div >
2026-09-27 17:05:11 +03:30
</ template >
< style scoped >
2026-10-18 19:39:59 +03:30
/* فقط ارتفاع/اسکرول ساختاری — بدون رنگ/ریاسکین Ant */
. lead - board - page {
width : 100 % ;
2026-09-27 17:05:11 +03:30
}
2026-10-18 19:39:59 +03:30
. lead - board - track {
scrollbar - width : thin ;
2026-09-27 17:05:11 +03:30
}
2026-10-18 19:39:59 +03:30
. lead - board - track ::- webkit - scrollbar {
height : 8 px ;
2026-09-27 17:05:11 +03:30
}
2026-10-18 19:39:59 +03:30
. lead - board - track ::- webkit - scrollbar - thumb {
border - radius : 999 px ;
background : color - mix ( in srgb , currentColor 28 % , transparent );
2026-09-27 17:05:11 +03:30
}
</ style >