@@ -55,6 +55,7 @@ import { canManageSettings, canViewAllRecords } from '@/lib/roles';
import { PAGE _SIZE _OPTIONS , usePageSize } from '@/lib/pageSize' ;
import { registerTableColumns , useTableColumns } from '@/lib/tableColumns' ;
import TableColumnsMenu from '@/components/TableColumnsMenu.vue' ;
import { bindMenuDismiss , cardMenuStyle } from '@/lib/anchored-menu' ;
const ACT _TABLE _KEY = 'activities' ;
const ACT _COLUMNS = [
@@ -75,9 +76,13 @@ function colOn(key: ActColKey) {
const col = ACT _COLUMNS . find ( ( c ) => c . key === key ) ! ;
return actCols . isVisible ( col . label ) ;
}
const selectMode = ref ( false ) ;
const selectedIds = ref < Set < string > > ( new Set ( ) ) ;
const selectedCount = computed ( ( ) => selectedIds . value . size ) ;
const actGridStyle = computed ( ( ) => ( {
gridTemplateColumns : [
'2.4rem' ,
... ( selectMode . value ? [ '2rem' ] : [ ] ) ,
... ACT _COLUMNS . filter ( ( c ) => actCols . isVisible ( c . label ) ) . map ( ( c ) => c . width ) ,
'2.4rem' ,
] . join ( ' ' ) ,
@@ -225,6 +230,11 @@ const activityModal = ref<{
stageId ? : string ;
} | null > ( null ) ;
const menuId = ref < string | null > ( null ) ;
const boardMenuId = ref < string | null > ( null ) ;
const boardMenuStyle = ref < Record < string , string > | null > ( null ) ;
const deleteActivityIds = ref < string [ ] > ( [ ] ) ;
const deleteSingleTitle = ref ( '' ) ;
const activityDeleting = ref ( false ) ;
const busy = ref ( false ) ;
const pageBusy = ref ( false ) ;
const busyMessage = ref ( 'لطفاً صبر کنید…' ) ;
@@ -561,6 +571,7 @@ function clearFacets() {
async function complete ( id : string ) {
menuId . value = null ;
boardMenuId . value = null ;
try {
await api . patch ( ` /activities/ ${ id } /complete ` ) ;
await refreshAll ( ) ;
@@ -574,6 +585,92 @@ function closePopovers() {
menuId . value = null ;
}
const deleteConfirmCopy = computed ( ( ) => {
const n = deleteActivityIds . value . length ;
if ( n <= 0 ) return '' ;
if ( n === 1 ) {
const title = deleteSingleTitle . value . trim ( ) ;
return title
? ` فعالیت « ${ title } » حذف شود؟ این کار قابل بازگشت نیست. `
: 'این فعالیت حذف شود؟ این کار قابل بازگشت نیست.' ;
}
return ` ${ toFaDigits ( n ) } فعالیت حذف شود؟ این کار قابل بازگشت نیست. ` ;
} ) ;
function activityTitle ( id : string ) {
return items . value . find ( ( item ) => item . id === id ) ? . title ? ? '' ;
}
function startSelect ( id : string ) {
menuId . value = null ;
boardMenuId . value = null ;
selectMode . value = true ;
selectedIds . value = new Set ( [ id ] ) ;
}
function toggleSelect ( id : string ) {
const next = new Set ( selectedIds . value ) ;
if ( next . has ( id ) ) next . delete ( id ) ;
else next . add ( id ) ;
selectedIds . value = next ;
if ( ! next . size ) selectMode . value = false ;
}
function clearSelect ( ) {
selectMode . value = false ;
selectedIds . value = new Set ( ) ;
}
function askDeleteActivity ( id : string , title ? : string ) {
menuId . value = null ;
boardMenuId . value = null ;
deleteSingleTitle . value = title ? . trim ( ) || activityTitle ( id ) ;
deleteActivityIds . value = [ id ] ;
}
function askBulkDelete ( ) {
if ( ! selectedIds . value . size ) return ;
menuId . value = null ;
boardMenuId . value = null ;
deleteSingleTitle . value = '' ;
deleteActivityIds . value = [ ... selectedIds . value ] ;
}
async function confirmDeleteActivities ( ) {
const ids = deleteActivityIds . value ;
if ( ! ids . length || activityDeleting . value ) return ;
activityDeleting . value = true ;
error . value = '' ;
try {
const results = await Promise . allSettled ( ids . map ( ( id ) => api . delete ( ` /activities/ ${ id } ` ) ) ) ;
const failed = results . filter ( ( r ) => r . status === 'rejected' ) . length ;
deleteActivityIds . value = [ ] ;
clearSelect ( ) ;
kanbanReload . value += 1 ;
await refreshAll ( ) ;
if ( failed ) {
error . value =
failed === ids . length
? 'حذف فعالیتها ناموفق بود.'
: ` ${ toFaDigits ( failed ) } مورد حذف نشد. ` ;
}
} finally {
activityDeleting . value = false ;
}
}
function onBoardMenu ( activityId : string ) {
boardMenuId . value = boardMenuId . value === activityId ? null : activityId ;
boardMenuStyle . value = boardMenuId . value ? cardMenuStyle ( activityId ) : null ;
}
const unbindBoardMenuDismiss = bindMenuDismiss (
( ) => boardMenuId . value != null ,
( ) => {
boardMenuId . value = null ;
} ,
) ;
async function exportExcel ( ) {
busy . value = true ;
pageBusy . value = true ;
@@ -679,8 +776,15 @@ onMounted(() => {
} ) ;
} ) ;
watch ( viewMode , ( ) => {
clearSelect ( ) ;
boardMenuId . value = null ;
menuId . value = null ;
} ) ;
onUnmounted ( ( ) => {
mobileQuery ? . removeEventListener ( 'change' , syncMobile ) ;
unbindBoardMenuDismiss ( ) ;
} ) ;
< / script >
@@ -897,16 +1001,36 @@ onUnmounted(() => {
< p v-if = "error" class="error" > {{ error }} < / p >
< p v-if = "importMsg" class="ok" > {{ importMsg }} < / p >
< div v-if = "selectMode" class="select-bar" >
< span > { { toFaDigits ( selectedCount ) } } فعالیت انتخاب شده < / span >
< button
v-if = "selectedCount"
type = "button"
class = "select-del"
@click.stop ="askBulkDelete"
>
حذف انتخاب شده ها
< / button >
< button type = "button" class = "select-cancel" @click.stop ="clearSelect" >
انصراف از انتخاب
< / button >
< / div >
< ActivitiesKanban
v-if = "viewMode === 'board'"
:stages = "stages"
:params = "sharedParams"
:can-manage = "canManageStages"
:reload-key = "kanbanReload"
:select-mode = "selectMode"
:selected-ids = "selectedIds"
@changed ="loadCounts"
@ stages -changed = " onStagesChanged "
@add ="openAddInStage"
@error ="(msg: string) => (error = msg)"
@ menu -deal = " onBoardMenu "
@ toggle -select = " toggleSelect "
@ bulk -delete = " askBulkDelete "
/ >
< p v-else-if = "loading" class="muted" > در حال بارگذاری … < / p >
@@ -965,6 +1089,7 @@ onUnmounted(() => {
< div class = "table desktop-only" role = "table" >
< div class = "thead" role = "row" :style = "actGridStyle" >
< span class = "row-num" > # < / span >
< span v-if = "selectMode" class="tick-col" / >
< template v-for = "col in ACT_COLUMNS" :key="col.key" >
< span v-if = "colOn(col.key)" > {{ col.label }} < / span >
< / template >
@@ -974,10 +1099,25 @@ onUnmounted(() => {
v-for = "(item, idx) in items"
:key = "item.id"
class = "trow"
: class = "{ selected: selectedIds.has(item.id), 'select-mode': selectMode }"
role = "row"
:style = "actGridStyle"
:data-card-id = "item.id"
@click ="selectMode && toggleSelect(item.id)"
>
< span class = "row-num" > { { toFaDigits ( ( page - 1 ) * pageSize + idx + 1 ) } } < / span >
< div v-if = "selectMode" class="tick-col" @click.stop >
< button
type = "button"
class = "tick"
: class = "{ on: selectedIds.has(item.id) }"
: aria -label = " selectedIds.has ( item.id ) ? ' لغو انتخاب ' : ' انتخاب فعالیت ' "
:aria-pressed = "selectedIds.has(item.id)"
@click ="toggleSelect(item.id)"
>
< UiIcon v-if = "selectedIds.has(item.id)" name="check" :size="14" / >
< / button >
< / div >
< div v-if = "colOn('title')" class="title-cell" >
< UiIcon name = "star" :size = "16" class = "star" / >
< div >
@@ -1037,14 +1177,24 @@ onUnmounted(() => {
< / small >
< / div >
< div v-if = "!isMobile" class="row-more" @click.stop >
< button
v-if = "selectMode && selectedIds.has(item.id)"
type = "button"
class = "icon-more trash menu"
aria -label = " حذف انتخابشدهها "
@click ="askBulkDelete"
>
< UiIcon name = "trash" :size = "16" / >
< / button >
< UiDropdownMenu
v-else
: open = "menuId === item.id"
align = "end"
@ update : open = "(v) => (menuId = v ? item.id : null)"
>
< template # trigger = "{ toggle }" >
< button
class = "icon-more"
class = "icon-more menu "
type = "button"
aria -label = " عملیات "
@click.stop ="toggle"
@@ -1052,6 +1202,7 @@ onUnmounted(() => {
< UiIcon name = "dots-v" :size = "16" / >
< / button >
< / template >
< button type = "button" @click ="startSelect(item.id)" > انتخاب < / button >
< RouterLink : to = "{ name: 'activity-detail', params: { id: item.id } }" >
مشاهده
< / RouterLink >
@@ -1061,17 +1212,42 @@ onUnmounted(() => {
< button v-if = "item.status === 'open'" type="button" @click="complete(item.id)" >
انجام شد
< / button >
< button type = "button" class = "danger" @click ="askDeleteActivity(item.id, item.title)" >
حذف
< / button >
< / UiDropdownMenu >
< / div >
< / article >
< / div >
< div class = "cards mobile-only" >
< article v-for = "item in items" :key="item.id" class="card" >
< article
v-for = "item in items"
:key = "item.id"
class = "card"
: class = "{ selected: selectedIds.has(item.id), 'select-mode': selectMode }"
:data-card-id = "item.id"
@click ="selectMode && toggleSelect(item.id)"
>
< div class = "card-top" >
< button
v-if = "selectMode"
type = "button"
class = "tick"
: class = "{ on: selectedIds.has(item.id) }"
: aria -label = " selectedIds.has ( item.id ) ? ' لغو انتخاب ' : ' انتخاب فعالیت ' "
:aria-pressed = "selectedIds.has(item.id)"
@click.stop ="toggleSelect(item.id)"
>
< UiIcon v-if = "selectedIds.has(item.id)" name="check" :size="14" / >
< / button >
< div class = "card-main" >
< time :class = "item.type" > { { clockText ( item ) } } < / time >
< RouterLink class = "title" : to = "{ name: 'activity-detail', params: { id: item.id } }" >
< RouterLink
class = "title"
: to = "{ name: 'activity-detail', params: { id: item.id } }"
@click ="selectMode && $event.preventDefault()"
>
{ { item . title } }
< UiIcon name = "star" :size = "14" class = "star" / >
< / RouterLink >
@@ -1088,15 +1264,24 @@ onUnmounted(() => {
< / p >
< / div >
< div class = "card-side" >
< button
v-if = "selectMode && selectedIds.has(item.id)"
type = "button"
class = "icon-more trash menu"
aria -label = " حذف انتخابشدهها "
@click.stop ="askBulkDelete"
>
< UiIcon name = "trash" :size = "16" / >
< / button >
< UiDropdownMenu
v-if = "isMobile"
v-else-if = "isMobile"
: open = "menuId === item.id"
align = "end"
@ update : open = "(v) => (menuId = v ? item.id : null)"
>
< template # trigger = "{ toggle }" >
< button
class = "icon-more"
class = "icon-more menu "
type = "button"
aria -label = " عملیات "
@click.stop ="toggle"
@@ -1104,12 +1289,16 @@ onUnmounted(() => {
< UiIcon name = "dots-v" :size = "16" / >
< / button >
< / template >
< button type = "button" @click ="startSelect(item.id)" > انتخاب < / button >
< RouterLink : to = "{ name: 'activity-detail', params: { id: item.id } }" >
مشاهده
< / RouterLink >
< button v-if = "item.status === 'open'" type="button" @click="complete(item.id)" >
انجام شد
< / button >
< button type = "button" class = "danger" @click ="askDeleteActivity(item.id, item.title)" >
حذف
< / button >
< / UiDropdownMenu >
< span class = "type-ico" :style = "typeColorStyle(activityTypeView(item).color)" >
< UiIcon :name = "activityTypeView(item).icon" :size = "18" / >
@@ -1143,6 +1332,39 @@ onUnmounted(() => {
/ >
< / template >
< div
v-if = "boardMenuId"
class = "menu-pop"
role = "menu"
: style = "boardMenuStyle ?? undefined"
@click.stop
>
< button type = "button" role = "menuitem" @click ="startSelect(boardMenuId)" > انتخاب < / button >
< RouterLink
role = "menuitem"
: to = "{ name: 'activity-detail', params: { id: boardMenuId } }"
@click ="boardMenuId = null"
>
مشاهده
< / RouterLink >
< RouterLink
role = "menuitem"
: to = "{ name: 'activity-edit', params: { id: boardMenuId } }"
@click ="boardMenuId = null"
>
ویرایش
< / RouterLink >
< button
type = "button"
role = "menuitem"
class = "danger"
@click ="askDeleteActivity(boardMenuId)"
>
حذف
< / button >
< button type = "button" role = "menuitem" @click ="boardMenuId = null" > بستن < / button >
< / div >
< UiModal
:open = "Boolean(activityModal)"
title = "فعالیت جدید"
@@ -1159,6 +1381,20 @@ onUnmounted(() => {
/ >
< / UiModal >
< UiModal
: open = "deleteActivityIds.length > 0"
: title = "deleteActivityIds.length > 1 ? 'حذف فعالیتها' : 'حذف فعالیت'"
@close ="deleteActivityIds = []"
>
< p class = "delete-copy" > { { deleteConfirmCopy } } < / p >
< template # footer >
< UiButton type = "button" :disabled = "activityDeleting" @click ="confirmDeleteActivities" >
{{ activityDeleting ? ' در حال حذف … ' : ' حذف ' }}
< / UiButton >
< UiButton variant = "ghost" type = "button" @click ="deleteActivityIds = []" > انصراف < / UiButton >
< / template >
< / UiModal >
< ActivityStagesManager
v-if = "canManageStages"
:open = "stagesManagerOpen"
@@ -1837,6 +2073,121 @@ time.note {
cursor : pointer ;
}
. icon - more . trash {
color : var ( -- danger , # b91c1c ) ;
}
. tick - col {
display : grid ;
place - items : center ;
}
. tick {
width : 1.7 rem ;
height : 1.7 rem ;
border : 1.5 px solid # cbd5e1 ;
border - radius : 999 px ;
background : # fff ;
color : # fff ;
display : grid ;
place - items : center ;
padding : 0 ;
cursor : pointer ;
}
. tick . on {
border - color : var ( -- accent , # 14 b8a6 ) ;
background : var ( -- accent , # 14 b8a6 ) ;
}
. trow . selected ,
. card . selected {
background : color - mix ( in srgb , var ( -- accent , # 14 b8a6 ) 6 % , # fff ) ;
}
. trow . select - mode {
cursor : pointer ;
}
. select - bar {
display : flex ;
align - items : center ;
flex - wrap : wrap ;
gap : 0.65 rem 1 rem ;
padding : 0.55 rem 0.85 rem ;
border - radius : var ( -- radius ) ;
background : color - mix ( in srgb , var ( -- accent , # 14 b8a6 ) 10 % , # fff ) ;
border : 1 px solid color - mix ( in srgb , var ( -- accent , # 14 b8a6 ) 25 % , # e2e8f0 ) ;
font - size : 0.86 rem ;
font - weight : 700 ;
color : var ( -- ink , # 0 f172a ) ;
}
. select - bar span {
flex : 1 ;
min - width : 8 rem ;
}
. select - bar button {
border : 0 ;
background : none ;
padding : 0 ;
font : inherit ;
font - size : 0.82 rem ;
font - weight : 600 ;
cursor : pointer ;
}
. select - bar . select - del {
color : var ( -- danger , # b91c1c ) ;
}
. select - bar . select - cancel {
color : var ( -- accent , # 0 f766e ) ;
}
. menu - pop {
position : fixed ;
inset - inline - start : 1 rem ;
bottom : calc ( var ( -- mobile - tabbar - h , 0 px ) + 1.75 rem ) ;
z - index : 40 ;
background : # fff ;
border : 1 px solid var ( -- line , # e2e8f0 ) ;
border - radius : var ( -- radius ) ;
box - shadow : var ( -- shadow , 0 16 px 40 px rgba ( 15 , 23 , 42 , 0.14 ) ) ;
padding : 0.35 rem ;
display : grid ;
gap : 0.15 rem ;
min - width : 10 rem ;
}
. menu - pop button ,
. menu - pop a {
border : 0 ;
background : transparent ;
padding : 0.45 rem 0.85 rem ;
cursor : pointer ;
font : inherit ;
color : var ( -- ink , # 0 f172a ) ;
text - align : right ;
text - decoration : none ;
border - radius : calc ( var ( -- radius ) - 4 px ) ;
}
. menu - pop button : hover ,
. menu - pop a : hover {
background : # f1f5f9 ;
}
. menu - pop button . danger {
color : var ( -- danger , # b91c1c ) ;
}
. delete - copy {
margin : 0 ;
line - height : 1.7 ;
}
. cards {
display : grid ;
gap : 0.75 rem ;