489 lines
15 KiB
Vue
489 lines
15 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 { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali';
|
|
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
|
|
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.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];
|
|
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 moving = ref(false);
|
|
const stageBusy = ref(false);
|
|
const localOrder = ref<string[] | null>(null);
|
|
|
|
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 dateText(item: ActivityDto) {
|
|
if (!item.dueAt) return 'بدون موعد';
|
|
const due = new Date(item.dueAt);
|
|
const days = calendarDaysBetween(due, new Date());
|
|
const day =
|
|
days === 0 ? 'امروز' : days === 1 ? 'دیروز' : days === -1 ? 'فردا' : formatJalaliDay(due);
|
|
const late =
|
|
item.status === 'open' &&
|
|
startOfLocalDay(due).getTime() < startOfLocalDay().getTime();
|
|
return `${late ? 'عقبافتاده · ' : ''}${day} · ${formatClockFa(due)}`;
|
|
}
|
|
|
|
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: dateText(item),
|
|
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,
|
|
};
|
|
}
|
|
|
|
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) {
|
|
draggingId.value = deal.id;
|
|
overlayDeal.value = deal;
|
|
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 };
|
|
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);
|
|
}
|
|
}
|
|
|
|
function onDragEnd() {
|
|
draggingId.value = null;
|
|
dropStageId.value = null;
|
|
overlayDeal.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 (!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)!;
|
|
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 ? 0 : 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;
|
|
try {
|
|
const { data } = await api.patch<ActivityDto>(`/activities/${activityId}/stage`, {
|
|
stageId: toStageId,
|
|
position: insertAt,
|
|
});
|
|
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 {
|
|
moving.value = false;
|
|
}
|
|
}
|
|
|
|
async function onDrop(stageId: string, event: DragEvent, index?: number) {
|
|
event.preventDefault();
|
|
const raw = event.dataTransfer?.getData('text/plain') || draggingColumnId.value || draggingId.value || '';
|
|
dropStageId.value = null;
|
|
draggingId.value = null;
|
|
overlayDeal.value = null;
|
|
if (raw.startsWith('stage:') || draggingColumnId.value) {
|
|
const fromId = raw.replace(/^stage:/, '') || draggingColumnId.value;
|
|
draggingColumnId.value = null;
|
|
if (fromId) await reorderColumn(fromId, stageId);
|
|
return;
|
|
}
|
|
if (!raw || moving.value) return;
|
|
await moveCard(raw, stageId, index);
|
|
}
|
|
|
|
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 openActivity(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.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;
|
|
void router.push({ name: 'activity-detail', params: { id }, query });
|
|
}
|
|
</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"
|
|
@drag-start="onDragStart"
|
|
@drag-end="onDragEnd"
|
|
@drag-over="onDragOver"
|
|
@drop="onDrop"
|
|
@add-deal="emit('add', $event)"
|
|
@open-deal="openActivity"
|
|
@menu-deal="onMenuActivity"
|
|
@toggle-select="emit('toggleSelect', $event)"
|
|
@bulk-delete="emit('bulkDelete')"
|
|
@rename-stage="onRenameStage"
|
|
@delete-stage="onDeleteStage"
|
|
@move-stage="onMoveStage"
|
|
@column-drag-start="(id: string) => (draggingColumnId = id)"
|
|
@column-drag-end="draggingColumnId = null; dropStageId = null"
|
|
@add-stage="onAddStage"
|
|
@load-more="loadMore"
|
|
/>
|
|
<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>
|