diff --git a/apps/web/src/components/activities/ActivitiesKanban.vue b/apps/web/src/components/activities/ActivitiesKanban.vue index 50f27ae..39d0d68 100644 --- a/apps/web/src/components/activities/ActivitiesKanban.vue +++ b/apps/web/src/components/activities/ActivitiesKanban.vue @@ -23,19 +23,27 @@ import { const PAGE = 30; -const props = defineProps<{ - stages: ActivityStageDto[]; - /** فیلترهای مشترک با حالت تب (filter زمانی، q، desk، type، assigneeId) */ - params: Record; - canManage: boolean; - reloadKey: number; -}>(); +const props = withDefaults( + defineProps<{ + stages: ActivityStageDto[]; + /** فیلترهای مشترک با حالت تب (filter زمانی، q، desk، type، assigneeId) */ + params: Record; + canManage: boolean; + reloadKey: number; + selectMode?: boolean; + selectedIds?: Set; + }>(), + { 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" diff --git a/apps/web/src/lib/anchored-menu.ts b/apps/web/src/lib/anchored-menu.ts index 7adb23c..dd5048d 100644 --- a/apps/web/src/lib/anchored-menu.ts +++ b/apps/web/src/lib/anchored-menu.ts @@ -4,9 +4,15 @@ export function cardMenuStyle( width = 160, height = 230, ): Record | null { - const btn = document.querySelector( - `[data-card-id="${CSS.escape(cardId)}"] .menu`, + const buttons = Array.from( + document.querySelectorAll(`[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; diff --git a/apps/web/src/views/ActivitiesView.vue b/apps/web/src/views/ActivitiesView.vue index 44dca83..d26f274 100644 --- a/apps/web/src/views/ActivitiesView.vue +++ b/apps/web/src/views/ActivitiesView.vue @@ -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>(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(null); +const boardMenuId = ref(null); +const boardMenuStyle = ref | null>(null); +const deleteActivityIds = ref([]); +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(); }); @@ -897,16 +1001,36 @@ onUnmounted(() => {

{{ error }}

{{ importMsg }}

+
+ {{ toFaDigits(selectedCount) }} فعالیت انتخاب شده + + +
+

در حال بارگذاری…

@@ -965,6 +1089,7 @@ onUnmounted(() => {
# + @@ -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)" > {{ toFaDigits((page - 1) * pageSize + idx + 1) }} +
+ +
@@ -1037,14 +1177,24 @@ onUnmounted(() => {
+ + مشاهده @@ -1061,17 +1212,42 @@ onUnmounted(() => { +
-
+
+
- + {{ item.title }} @@ -1088,15 +1264,24 @@ onUnmounted(() => {

+ + مشاهده + @@ -1143,6 +1332,39 @@ onUnmounted(() => { /> + + { /> + +

{{ deleteConfirmCopy }}

+ +
+ (menuOpenId.value = null), ); +watch(viewMode, () => { + clearSelect(); + menuOpenId.value = null; +}); + watch( () => route.query.pipelineId, (id, prev) => {