Files
CRM_Montzar/apps/web/src/components/activities/ActivitiesKanban.vue
T
MahdiandCursor 33eeb3242a Add activity priority desk filter and keep detail browsing inside the same column after stage changes.
When done/reopen/stage moves leave a column, advance to the next activity there or return to the list if empty.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 12:02:13 +03:30

616 lines
19 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import axios from 'axios';
import type {
ActivityDto,
ActivityStageDto,
PaginatedResponse,
} from '@mento/shared';
import { api } from '@/lib/api';
import { activityTypeView } from '@/lib/activity-types';
import { formatRelativeDayLabel, toFaDigits } from '@/lib/jalali';
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
import ActivityQuickView from '@/components/activities/ActivityQuickView.vue';
import {
ACTIVITY_BOARD_COPY,
initialsFromTitle,
moveStageToIndex,
type PipelineAccent,
type PipelineDeal,
type PipelineStage,
} from '@/lib/pipeline-board';
const PAGE = 30;
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];
notice: [message: string];
menuDeal: [activity: ActivityDto, stageId?: string];
toggleSelect: [activityId: string];
bulkDelete: [];
}>();
const router = useRouter();
type Column = {
items: ActivityDto[];
total: number;
page: number;
loading: boolean;
};
const columns = ref<Record<string, Column>>({});
const initialLoading = ref(false);
const draggingId = ref<string | null>(null);
const dropStageId = ref<string | null>(null);
const draggingColumnId = ref<string | null>(null);
const overlayDeal = ref<PipelineDeal | null>(null);
const overlayAccent = ref<PipelineAccent>('green');
const overlayPos = ref({ x: 0, y: 0 });
const quickId = ref<string | null>(null);
const peekId = ref<string | null>(null);
let peekLinger = 0;
const moving = ref(false);
const stageBusy = ref(false);
const localOrder = ref<string[] | null>(null);
/** صف جابه‌جایی تا دراپ پشت‌سرهم قفل نشود؛ در watchdog هم ریست می‌شود */
let moveChain: Promise<void> = Promise.resolve();
let moveWatchdog = 0;
/** هویت درگ بیرون از reactivity تا clear/re-render وسط drop شناسه را نبلعد */
let dragSessionId: string | null = null;
let dragVisualTimer = 0;
function clearDragState() {
window.clearTimeout(dragVisualTimer);
dragSessionId = null;
draggingId.value = null;
dropStageId.value = null;
overlayDeal.value = null;
draggingColumnId.value = null;
}
function resetMoveLock() {
window.clearTimeout(moveWatchdog);
moving.value = false;
moveChain = Promise.resolve();
}
function armMoveWatchdog() {
window.clearTimeout(moveWatchdog);
moveWatchdog = window.setTimeout(() => {
resetMoveLock();
}, 12_000);
}
function enqueueMove(task: () => Promise<void>) {
const run = moveChain.then(task, task);
moveChain = run.then(
() => undefined,
() => undefined,
);
return run;
}
const KIND_BADGE: Record<ActivityStageDto['kind'], string | undefined> = {
open: undefined,
done: 'انجام‌شده',
canceled: 'لغو',
};
const orderedStages = computed(() => {
if (!localOrder.value) return props.stages;
const byId = new Map(props.stages.map((s) => [s.id, s]));
return localOrder.value.map((id) => byId.get(id)).filter(Boolean) as ActivityStageDto[];
});
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;
}
return fallback;
}
function toCard(item: ActivityDto, stageId: string): PipelineDeal {
const party = item.company?.name || item.person?.fullName || item.deal?.title || '';
const assigneeNames = item.assignees?.length
? item.assignees.map((a) => a.fullName)
: item.assignee
? [item.assignee.fullName]
: [];
return {
id: item.id,
title: item.title,
description: [activityTypeView(item).name, party].filter(Boolean).join(' · '),
amount: null,
date:
item.status === 'done'
? 'انجام شده'
: item.dueAt
? formatRelativeDayLabel(item.dueAt)
: 'بدون موعد',
dateIso: item.dueAt ?? undefined,
initials: initialsFromTitle(party || item.title),
stageId,
createdAt: item.createdAt,
promotedFromPriorAt: item.promotedFromPriorAt ?? null,
assigneeNames,
ownerId: item.assignee?.id ?? null,
assigneeIds: (item.assignees ?? []).map((a) => a.id),
priority: item.priority,
activityStatus: item.status,
};
}
const boardStages = computed<PipelineStage[]>(() =>
orderedStages.value.map((s) => {
const col = columns.value[s.id];
const items = col?.items ?? [];
const total = col?.total ?? 0;
return {
id: s.id,
title: s.name,
color: s.color as PipelineAccent,
deals: items.map((item) => toCard(item, s.id)),
total,
hasMore: items.length < total,
loadingMore: col?.loading ?? false,
badge: KIND_BADGE[s.kind],
};
}),
);
async function fetchPage(stageId: string, page: number) {
const { data } = await api.get<PaginatedResponse<ActivityDto>>('/activities', {
params: { ...props.params, stageId, page, limit: PAGE },
});
return data;
}
let loadSeq = 0;
async function loadAll() {
const seq = ++loadSeq;
initialLoading.value = true;
try {
const results = await Promise.all(
props.stages.map(async (s) => [s.id, await fetchPage(s.id, 1)] as const),
);
if (seq !== loadSeq) return;
const next: Record<string, Column> = {};
for (const [id, res] of results) {
next[id] = { items: res.items, total: res.total, page: 1, loading: false };
}
columns.value = next;
} catch (e) {
if (seq === loadSeq) emit('error', apiMessage(e, 'بارگذاری ستون‌های فعالیت ناموفق بود.'));
} finally {
if (seq === loadSeq) initialLoading.value = false;
}
}
async function loadMore(stageId: string) {
const col = columns.value[stageId];
if (!col || col.loading) return;
col.loading = true;
try {
const res = await fetchPage(stageId, col.page + 1);
const seen = new Set(col.items.map((i) => i.id));
col.items = [...col.items, ...res.items.filter((i) => !seen.has(i.id))];
col.total = res.total;
col.page += 1;
} catch (e) {
emit('error', apiMessage(e, 'بارگذاری بیشتر ناموفق بود.'));
} finally {
col.loading = false;
}
}
watch(
() => [
props.stages.map((s) => s.id).sort().join(','),
JSON.stringify(props.params),
props.reloadKey,
],
() => void loadAll(),
{ immediate: true },
);
watch(
() => props.stages,
() => {
localOrder.value = null;
},
);
function findStageOf(id: string) {
return Object.entries(columns.value).find(([, col]) => col.items.some((i) => i.id === id))?.[0];
}
function findActivity(id: string) {
for (const col of Object.values(columns.value)) {
const item = col.items.find((i) => i.id === id);
if (item) return item;
}
return null;
}
function onMenuActivity(id: string) {
const item = findActivity(id);
if (item) emit('menuDeal', item, findStageOf(id));
}
function onDragStart(deal: PipelineDeal, event: DragEvent) {
draggingColumnId.value = null;
// قفل/صف مانده از درخواست قبلی را باز کن
if (moving.value) resetMoveLock();
dragSessionId = deal.id;
// setData / setDragImage باید همزمان با dragstart باشند
event.dataTransfer?.setData('text/plain', deal.id);
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
const img = new Image();
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
event.dataTransfer.setDragImage(img, 0, 0);
}
const sid = findStageOf(deal.id);
overlayAccent.value =
(props.stages.find((s) => s.id === sid)?.color as PipelineAccent | undefined) ?? 'green';
overlayPos.value = { x: (event.clientX || 0) + 12, y: (event.clientY || 0) + 12 };
// تغییر DOM (opacity/overlay/بستن peek) داخل dragstart درگ HTML5 را در کروم لغو می‌کند
window.clearTimeout(dragVisualTimer);
dragVisualTimer = window.setTimeout(() => {
if (dragSessionId !== deal.id) return;
draggingId.value = deal.id;
overlayDeal.value = deal;
if (quickId.value) closeQuick();
}, 0);
}
function onDragEnd() {
const endedId = dragSessionId ?? draggingId.value;
window.setTimeout(() => {
if (dragSessionId && dragSessionId !== endedId) return;
if (draggingId.value && draggingId.value !== endedId) return;
clearDragState();
}, 0);
}
function onColumnDragStart(id: string) {
window.clearTimeout(dragVisualTimer);
dragSessionId = null;
draggingId.value = null;
overlayDeal.value = null;
draggingColumnId.value = id;
}
function onColumnDragEnd() {
draggingColumnId.value = null;
dropStageId.value = null;
}
function onDragOver(stageId: string, event: DragEvent) {
event.preventDefault();
dropStageId.value = stageId;
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
if (event.clientX || event.clientY) {
overlayPos.value = { x: event.clientX + 12, y: event.clientY + 12 };
}
}
async function moveCard(activityId: string, toStageId: string, index?: number) {
const fromStageId = findStageOf(activityId);
if (!fromStageId) return;
const from = columns.value[fromStageId];
const to = columns.value[toStageId];
if (!from || !to) return;
const fromIndex = from.items.findIndex((i) => i.id === activityId);
const item = from.items[fromIndex];
if (!item) return;
const toStage = props.stages.find((s) => s.id === toStageId);
if (!toStage) return;
const same = fromStageId === toStageId;
const without = (same ? from.items : to.items).filter((i) => i.id !== activityId);
if (same && index == null) return;
// بدون ایندکس صریح → ته ستون (هم‌تراز با بورد معامله)
let insertAt = index == null || index < 0 ? without.length : index;
if (same && index != null && index > fromIndex) insertAt -= 1;
insertAt = Math.max(0, Math.min(insertAt, without.length));
if (same && insertAt === fromIndex) return;
const prevFrom = from.items;
const prevTo = to.items;
const prevFromTotal = from.total;
const prevToTotal = to.total;
const optimistic: ActivityDto = { ...item, status: toStage.kind, stageId: toStageId };
const nextTo = [...without];
nextTo.splice(insertAt, 0, optimistic);
if (same) {
from.items = nextTo;
} else {
from.items = from.items.filter((i) => i.id !== activityId);
from.total = Math.max(0, from.total - 1);
to.items = nextTo;
to.total += 1;
}
moving.value = true;
armMoveWatchdog();
try {
const { data } = await api.patch<ActivityDto>(`/activities/${activityId}/stage`, {
stageId: toStageId,
position: insertAt,
});
if (!same && data.status !== toStage.kind) {
const hideFromMine = props.params.desk === 'mine';
from.items = hideFromMine
? prevFrom.filter((row) => row.id !== activityId)
: prevFrom.map((row) => (row.id === activityId ? data : row));
from.total = hideFromMine ? Math.max(0, prevFromTotal - 1) : prevFromTotal;
to.items = prevTo;
to.total = prevToTotal;
emit(
'notice',
'سهم شما ثبت شد. فعالیت وقتی انجام‌شده می‌شود که همهٔ مسئول‌ها سهم خود را بزنند.',
);
emit('changed');
return;
}
const column = same ? from : to;
column.items = column.items.map((i) => (i.id === activityId ? data : i));
emit('changed');
} catch (e) {
from.items = prevFrom;
from.total = prevFromTotal;
if (!same) {
to.items = prevTo;
to.total = prevToTotal;
}
emit('error', apiMessage(e, 'جابه‌جایی فعالیت ناموفق بود.'));
} finally {
window.clearTimeout(moveWatchdog);
moving.value = false;
}
}
async function onDrop(stageId: string, event: DragEvent, index?: number) {
event.preventDefault();
const transfer = event.dataTransfer?.getData('text/plain') || '';
const cardId = dragSessionId || draggingId.value;
const columnId = draggingColumnId.value;
clearDragState();
// کارت همیشه بر payload ستون اولویت دارد تا درگ کارت به‌اشتباه reorder ستون نشود
const activityId =
cardId || (transfer && !transfer.startsWith('stage:') ? transfer : '');
if (activityId) {
// fire-and-forget: await روی زنجیرهٔ گیرکرده نباید drop بعدی را قفل کند
void enqueueMove(() => moveCard(activityId, stageId, index));
return;
}
const isStageDrag = transfer.startsWith('stage:');
if (isStageDrag || columnId) {
const fromId = isStageDrag ? transfer.slice('stage:'.length) : columnId;
if (fromId) await reorderColumn(fromId, stageId);
}
}
async function persistOrder(ids: string[]) {
const prev = localOrder.value;
localOrder.value = ids;
stageBusy.value = true;
try {
await api.patch('/activities/stages/reorder', { stageIds: ids });
emit('stagesChanged');
} catch (e) {
localOrder.value = prev;
emit('error', apiMessage(e, 'جابه‌جایی ستون ناموفق بود.'));
} finally {
stageBusy.value = false;
}
}
async function reorderColumn(fromId: string, toId: string) {
if (!props.canManage || stageBusy.value || fromId === toId) return;
const current = boardStages.value;
const to = current.findIndex((s) => s.id === toId);
if (to < 0) return;
await persistOrder(moveStageToIndex(current, fromId, to).map((s) => s.id));
}
async function onMoveStage(stageId: string, dir: -1 | 1) {
if (stageBusy.value) return;
const current = boardStages.value;
const from = current.findIndex((s) => s.id === stageId);
const to = from + dir;
if (from < 0 || to < 0 || to >= current.length) return;
await persistOrder(moveStageToIndex(current, stageId, to).map((s) => s.id));
}
async function onRenameStage(stageId: string, name: string) {
if (stageBusy.value) return;
stageBusy.value = true;
try {
await api.patch(`/activities/stages/${stageId}`, { name });
emit('stagesChanged');
} catch (e) {
emit('error', apiMessage(e, 'تغییر نام ستون ناموفق بود.'));
} finally {
stageBusy.value = false;
}
}
async function onDeleteStage(stageId: string) {
if (stageBusy.value) return;
const stage = props.stages.find((s) => s.id === stageId);
const total = columns.value[stageId]?.total ?? 0;
const ok = window.confirm(
`ستون «${stage?.name ?? ''}» حذف شود؟${
total ? `\n${toFaDigits(total)} فعالیت این ستون به اولین ستون هم‌وضعیت منتقل می‌شوند.` : ''
}`,
);
if (!ok) return;
stageBusy.value = true;
try {
await api.delete(`/activities/stages/${stageId}`);
emit('stagesChanged');
emit('changed');
} catch (e) {
emit('error', apiMessage(e, 'حذف ستون ناموفق بود.'));
} finally {
stageBusy.value = false;
}
}
async function onAddStage(name: string) {
if (stageBusy.value) return;
stageBusy.value = true;
try {
await api.post('/activities/stages', { name, kind: 'open', boardId: props.params.boardId });
emit('stagesChanged');
} catch (e) {
emit('error', apiMessage(e, 'افزودن ستون ناموفق بود.'));
} finally {
stageBusy.value = false;
}
}
function activityQuery(id: string) {
const query: Record<string, string> = {
from: 'desk',
nav: crypto.randomUUID(),
view: 'board',
};
const source = props.params;
if (source.filter) query.filter = source.filter;
if (source.boardId) query.board = source.boardId;
if (source.desk) query.desk = source.desk;
if (source.q) query.q = source.q;
if (source.typeDefId) query.typeDefId = source.typeDefId;
else if (source.type) query.type = source.type;
if (source.status) query.status = source.status;
if (source.priority) query.priority = source.priority;
if (source.assigneeId) query.assigneeId = source.assigneeId;
if (source.title) query.title = source.title;
if (source.customer) query.customer = source.customer;
if (source.due) query.due = source.due;
const columnId = findStageOf(id);
if (columnId) query.stage = columnId;
return query;
}
function openActivity(id: string) {
if (quickId.value === id) {
closeQuick();
return;
}
window.clearTimeout(peekLinger);
quickId.value = id;
peekId.value = id;
}
function closeQuick() {
quickId.value = null;
const id = peekId.value;
window.clearTimeout(peekLinger);
peekLinger = window.setTimeout(() => {
if (!quickId.value && peekId.value === id) peekId.value = null;
}, 650);
}
function openFullActivity(id: string) {
quickId.value = null;
void router.push({ name: 'activity-detail', params: { id }, query: activityQuery(id) });
}
</script>
<template>
<div class="kanban-wrap">
<p v-if="initialLoading && !Object.keys(columns).length" class="muted">در حال بارگذاری ستون‌ها…</p>
<PipelineBoard
v-else
:stages="boardStages"
:dragging-id="draggingId"
:drop-stage-id="dropStageId"
:can-manage="canManage"
:copy="ACTIVITY_BOARD_COPY"
:select-mode="selectMode"
:selected-ids="selectedIds"
:peek-id="peekId"
:preview-id="quickId"
@drag-start="onDragStart"
@drag-end="onDragEnd"
@drag-over="onDragOver"
@drop="onDrop"
@add-deal="emit('add', $event)"
@open-deal="openActivity"
@detail-deal="openFullActivity"
@menu-deal="onMenuActivity"
@toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')"
@rename-stage="onRenameStage"
@delete-stage="onDeleteStage"
@move-stage="onMoveStage"
@column-drag-start="onColumnDragStart"
@column-drag-end="onColumnDragEnd"
@add-stage="onAddStage"
@load-more="loadMore"
/>
<ActivityQuickView
:activity-id="quickId"
@close="closeQuick"
@changed="loadAll()"
@full="openFullActivity"
/>
<Teleport to="body">
<DealCardOverlay
v-if="overlayDeal && draggingId"
:deal="overlayDeal"
:accent="overlayAccent"
:copy="ACTIVITY_BOARD_COPY"
:x="overlayPos.x"
:y="overlayPos.y"
/>
</Teleport>
</div>
</template>
<style scoped>
.kanban-wrap {
container-type: inline-size;
container-name: pipeline;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.muted {
margin: 0;
color: #64748b;
}
</style>