@@ -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 >