feat: allow bulk select and delete on activities and deal list
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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.7rem;
|
||||
height: 1.7rem;
|
||||
border: 1.5px solid #cbd5e1;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tick.on {
|
||||
border-color: var(--accent, #14b8a6);
|
||||
background: var(--accent, #14b8a6);
|
||||
}
|
||||
|
||||
.trow.selected,
|
||||
.card.selected {
|
||||
background: color-mix(in srgb, var(--accent, #14b8a6) 6%, #fff);
|
||||
}
|
||||
|
||||
.trow.select-mode {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem 1rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--accent, #14b8a6) 10%, #fff);
|
||||
border: 1px solid color-mix(in srgb, var(--accent, #14b8a6) 25%, #e2e8f0);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink, #0f172a);
|
||||
}
|
||||
|
||||
.select-bar span {
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.select-bar button {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select-bar .select-del {
|
||||
color: var(--danger, #b91c1c);
|
||||
}
|
||||
|
||||
.select-bar .select-cancel {
|
||||
color: var(--accent, #0f766e);
|
||||
}
|
||||
|
||||
.menu-pop {
|
||||
position: fixed;
|
||||
inset-inline-start: 1rem;
|
||||
bottom: calc(var(--mobile-tabbar-h, 0px) + 1.75rem);
|
||||
z-index: 40;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line, #e2e8f0);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow, 0 16px 40px rgba(15, 23, 42, 0.14));
|
||||
padding: 0.35rem;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
.menu-pop button,
|
||||
.menu-pop a {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0.45rem 0.85rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: var(--ink, #0f172a);
|
||||
text-align: right;
|
||||
text-decoration: none;
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
}
|
||||
|
||||
.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.75rem;
|
||||
|
||||
Reference in New Issue
Block a user