feat: allow bulk select and delete on activities and deal list

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-17 12:47:07 +03:30
co-authored by Cursor
parent 9611803567
commit 2c7c943802
4 changed files with 389 additions and 15 deletions
@@ -23,19 +23,27 @@ import {
const PAGE = 30;
const props = defineProps<{
stages: ActivityStageDto[];
/** فیلترهای مشترک با حالت تب (filter زمانی، q، desk، type، assigneeId) */
params: Record<string, string | undefined>;
canManage: boolean;
reloadKey: number;
}>();
const props = withDefaults(
defineProps<{
stages: ActivityStageDto[];
/** فیلترهای مشترک با حالت تب (filter زمانی، q، desk، type، assigneeId) */
params: Record<string, string | undefined>;
canManage: boolean;
reloadKey: number;
selectMode?: boolean;
selectedIds?: Set<string>;
}>(),
{ selectedIds: () => new Set() },
);
const emit = defineEmits<{
changed: [];
stagesChanged: [];
add: [stageId: string];
error: [message: string];
menuDeal: [activityId: string];
toggleSelect: [activityId: string];
bulkDelete: [];
}>();
const router = useRouter();
@@ -381,13 +389,17 @@ function openActivity(id: string) {
:drop-stage-id="dropStageId"
:can-manage="canManage"
:copy="ACTIVITY_BOARD_COPY"
:select-mode="selectMode"
:selected-ids="selectedIds"
@drag-start="onDragStart"
@drag-end="onDragEnd"
@drag-over="onDragOver"
@drop="onDrop"
@add-deal="emit('add', $event)"
@open-deal="openActivity"
@menu-deal="openActivity"
@menu-deal="emit('menuDeal', $event)"
@toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')"
@rename-stage="onRenameStage"
@delete-stage="onDeleteStage"
@move-stage="onMoveStage"
+8 -2
View File
@@ -4,9 +4,15 @@ export function cardMenuStyle(
width = 160,
height = 230,
): Record<string, string> | null {
const btn = document.querySelector<HTMLElement>(
`[data-card-id="${CSS.escape(cardId)}"] .menu`,
const buttons = Array.from(
document.querySelectorAll<HTMLElement>(`[data-card-id="${CSS.escape(cardId)}"] .menu`),
);
// در نمای لیست هم جدول و هم کارت در DOM هستند؛ دکمهٔ واقعاً دیده را بگیر
const btn =
buttons.find((el) => {
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
}) ?? buttons[0];
if (!btn) return null;
const r = btn.getBoundingClientRect();
const gap = 4;
+356 -5
View File
@@ -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;
+5
View File
@@ -567,6 +567,11 @@ const unbindMenuDismiss = bindMenuDismiss(
() => (menuOpenId.value = null),
);
watch(viewMode, () => {
clearSelect();
menuOpenId.value = null;
});
watch(
() => route.query.pipelineId,
(id, prev) => {