fix(boards): keep card order inside a column and show activity priority

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-03 21:22:30 +03:30
co-authored by Cursor
parent bffe174162
commit 52edccf74e
14 changed files with 249 additions and 62 deletions
@@ -120,6 +120,7 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
assigneeNames,
ownerId: item.assignee?.id ?? null,
assigneeIds: (item.assignees ?? []).map((a) => a.id),
priority: item.priority,
};
}
@@ -253,40 +254,54 @@ function onDragOver(stageId: string, event: DragEvent) {
async function moveCard(activityId: string, toStageId: string, index?: number) {
const fromStageId = findStageOf(activityId);
if (!fromStageId || fromStageId === toStageId) return;
if (!fromStageId) return;
const from = columns.value[fromStageId]!;
const to = columns.value[toStageId];
if (!to) return;
const item = from.items.find((i) => i.id === activityId)!;
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 snapshot = {
fromItems: from.items,
fromTotal: from.total,
toItems: to.items,
toTotal: to.total,
};
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 };
from.items = from.items.filter((i) => i.id !== activityId);
from.total = Math.max(0, from.total - 1);
const nextTo = [...to.items];
const at = index == null || index < 0 || index > nextTo.length ? 0 : index;
nextTo.splice(at, 0, optimistic);
to.items = nextTo;
to.total += 1;
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,
});
to.items = to.items.map((i) => (i.id === activityId ? data : i));
const column = same ? from : to;
column.items = column.items.map((i) => (i.id === activityId ? data : i));
emit('changed');
} catch (e) {
from.items = snapshot.fromItems;
from.total = snapshot.fromTotal;
to.items = snapshot.toItems;
to.total = snapshot.toTotal;
from.items = prevFrom;
from.total = prevFromTotal;
if (!same) {
to.items = prevTo;
to.total = prevToTotal;
}
emit('error', apiMessage(e, 'جابه‌جایی فعالیت ناموفق بود.'));
} finally {
moving.value = false;
@@ -4,6 +4,7 @@ import {
CalendarOutlined,
CheckOutlined,
DeleteOutlined,
FlagFilled,
MoreOutlined,
} from '@ant-design/icons-vue';
import {
@@ -13,10 +14,12 @@ import {
Flex as AFlex,
Space as ASpace,
Tag as ATag,
Tooltip as ATooltip,
Typography,
theme,
} from 'ant-design-vue';
import { userAvatarSrc } from '@/lib/userAvatars';
import type { ActivityPriority } from '@mento/shared';
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
import { DEAL_BOARD_COPY, formatCurrencyParts } from '@/lib/pipeline-board';
@@ -60,6 +63,16 @@ const extraAssignees = computed(() =>
Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1),
);
const showTrash = computed(() => props.selectMode && props.selected);
const PRIORITY_FLAG: Record<ActivityPriority, { label: string; color: string }> = {
very_high: { label: 'خیلی زیاد', color: '#dc2626' },
high: { label: 'زیاد', color: '#ea580c' },
medium: { label: 'متوسط', color: '#0d9488' },
low: { label: 'کم', color: '#2563eb' },
very_low: { label: 'خیلی کم', color: '#94a3b8' },
};
const priorityFlag = computed(() =>
props.deal.priority ? PRIORITY_FLAG[props.deal.priority] : null,
);
const cardStyle = computed(() => ({
cursor: props.selectMode ? 'pointer' : 'grab',
@@ -140,6 +153,11 @@ const cardStyle = computed(() => ({
</a-typography-text>
</div>
<a-tooltip v-if="priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
<span :style="{ display: 'inline-flex', flexShrink: 0, lineHeight: 1 }">
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '16px' }" />
</span>
</a-tooltip>
<span :data-card-id="deal.id">
<a-button
v-if="showTrash"
@@ -13,7 +13,7 @@ import {
import AddDealButton from './AddDealButton.vue';
import DealCard from './DealCard.vue';
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board';
import { DEAL_BOARD_COPY, PIPELINE_ACCENT } from '@/lib/pipeline-board';
import { DEAL_BOARD_COPY, PIPELINE_ACCENT, pointerDropIndex } from '@/lib/pipeline-board';
const props = withDefaults(
defineProps<{
@@ -115,7 +115,7 @@ function onCardDragOver(stageId: string, event: DragEvent) {
function onCardDrop(stageId: string, index: number, event: DragEvent) {
event.preventDefault();
event.stopPropagation();
emit('drop', stageId, event, index);
emit('drop', stageId, event, pointerDropIndex(index, event));
}
function startRename() {
+14 -5
View File
@@ -1,5 +1,6 @@
import { currencySuffix } from '@/lib/money';
import { matchesSearch } from '@/lib/search';
import type { ActivityPriority } from '@mento/shared';
export type PipelineAccent = 'green' | 'orange' | 'purple' | 'blue' | 'rose' | 'slate';
@@ -21,6 +22,8 @@ export type PipelineDeal = {
assigneeIds?: string[];
/** متن‌های قابل جستجو (مشتری، شرکت، یادداشت، مسئولان …) */
searchFields?: Array<string | null | undefined>;
/** اولویت فعالیت؛ فقط روی کارت فعالیت نشان داده می‌شود */
priority?: ActivityPriority;
};
export type PipelineUiCopy = {
@@ -344,18 +347,16 @@ export function updateDealStage(
return without.map((stage) => {
if (stage.id !== stageId) return stage;
const next = [...stage.deals];
let insertAt =
toIndex == null || toIndex < 0 || toIndex > next.length
? next.length
: toIndex;
let insertAt = toIndex == null || toIndex < 0 ? next.length : toIndex;
if (
fromStageId === stageId &&
fromIndex >= 0 &&
toIndex != null &&
toIndex > fromIndex
) {
insertAt = Math.max(0, insertAt - 1);
insertAt -= 1;
}
insertAt = Math.max(0, Math.min(insertAt, next.length));
next.splice(insertAt, 0, { ...moving!, stageId });
return { ...stage, deals: next };
});
@@ -422,6 +423,14 @@ export function toFaDealCount(n: number): string {
}
/** جابه‌جایی یک ستون به ایندکس جدید */
/** نیمهٔ پایین کارت یعنی «بعد از این کارت». ایندکس نسبت به فهرست فعلی ستون است. */
export function pointerDropIndex(index: number, event: DragEvent): number {
const el = event.currentTarget as HTMLElement | null;
const rect = el?.getBoundingClientRect();
if (!rect || rect.height <= 0) return index;
return index + (event.clientY >= rect.top + rect.height / 2 ? 1 : 0);
}
export function moveStageToIndex(
stages: PipelineStage[],
stageId: string,
+29 -14
View File
@@ -32,6 +32,7 @@ import {
import { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { canManageSettings } from '@/lib/roles';
import { pointerDropIndex } from '@/lib/pipeline-board';
import { useAuthStore } from '@/stores/auth';
import type { LeadBoardColumnDto, LeadDto, LeadStageDto } from '@mento/shared';
@@ -481,28 +482,41 @@ function onDragOver(stageId: string, event: DragEvent) {
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
}
async function onDrop(stageId: string, event: DragEvent) {
async function onDrop(stageId: string, event: DragEvent, index?: number) {
event.preventDefault();
event.stopPropagation();
const leadId =
event.dataTransfer?.getData('text/plain') || draggingId.value || '';
draggingId.value = null;
dropStageId.value = null;
if (!leadId) return;
if (!leadId || moving.value) return;
const fromCol = columns.value.find((c) =>
c.leads.some((l) => l.id === leadId),
);
if (!fromCol || fromCol.stage.id === stageId) return;
const lead = fromCol.leads.find((l) => l.id === leadId);
const target = columns.value.find((c) => c.stage.id === stageId);
if (!fromCol || !target) return;
const fromIndex = fromCol.leads.findIndex((l) => l.id === leadId);
const lead = fromCol.leads[fromIndex];
if (!lead) return;
const same = fromCol.stage.id === stageId;
if (same && index == null) return;
const without = (same ? fromCol.leads : target.leads).filter((l) => l.id !== leadId);
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 nextLeads = [...without];
nextLeads.splice(insertAt, 0, { ...lead, stageId, stageName: target.stage.name });
const prev = columns.value.map((c) => ({
...c,
leads: [...c.leads],
}));
columns.value = columns.value.map((c) => {
if (same && c.stage.id === stageId) return { ...c, leads: nextLeads };
if (c.stage.id === fromCol.stage.id) {
return {
...c,
@@ -511,11 +525,7 @@ async function onDrop(stageId: string, event: DragEvent) {
};
}
if (c.stage.id === stageId) {
return {
...c,
total: c.total + 1,
leads: [{ ...lead, stageId, stageName: c.stage.name }, ...c.leads],
};
return { ...c, total: c.total + 1, leads: nextLeads };
}
return c;
});
@@ -523,7 +533,7 @@ async function onDrop(stageId: string, event: DragEvent) {
moving.value = true;
error.value = '';
try {
await api.patch(`/leads/${leadId}/move`, { stageId });
await api.patch(`/leads/${leadId}/move`, { stageId, position: insertAt });
} catch {
columns.value = prev;
error.value = 'جابه‌جایی لید ناموفق بود.';
@@ -543,7 +553,7 @@ onMounted(load);
مسیر لیدها
</a-typography-title>
<a-typography-text type="secondary">
کشیدن کارت برای تغییر وضعیت. از منوی هر ستون می‌توانید نام، ترتیب و حذف را عوض کنید.
کشیدن کارت برای تغییر ستون یا جابه‌جایی داخل همان ستون. از منوی هر ستون می‌توانید نام، ترتیب و حذف را عوض کنید.
</a-typography-text>
</div>
<a-space :size="GAP">
@@ -647,9 +657,13 @@ onMounted(load);
</div>
<div :style="columnBodyStyle">
<a-card
v-for="lead in col.leads"
<div
v-for="(lead, index) in col.leads"
:key="lead.id"
@dragover.prevent="onDragOver(col.stage.id, $event)"
@drop.stop="onDrop(col.stage.id, $event, pointerDropIndex(index, $event))"
>
<a-card
hoverable
:bordered="true"
:body-style="cardBodyStyle"
@@ -789,6 +803,7 @@ onMounted(load);
</a-flex>
</a-flex>
</a-card>
</div>
<a-typography-text
v-if="!col.leads.length"
+12 -5
View File
@@ -312,11 +312,18 @@ async function onDrop(stageId: string, event: DragEvent, index?: number) {
const dealId = raw;
if (!dealId || moving.value) return;
const current = stages.value.find((s) => s.deals.some((d) => d.id === dealId));
const currentIndex = current?.deals.findIndex((d) => d.id === dealId) ?? -1;
if (current?.id === stageId && (index == null || index === currentIndex)) {
return;
}
const current = stages.value.find((stage) => stage.deals.some((deal) => deal.id === dealId));
if (current?.id === stageId && index == null) return;
const next = updateDealStage(stages.value, dealId, stageId, index);
const unchanged = next.every((stage, stageIndex) => {
const prev = stages.value[stageIndex];
return (
stage.deals.length === (prev?.deals.length ?? -1) &&
stage.deals.every((deal, dealIndex) => deal.id === prev?.deals[dealIndex]?.id)
);
});
if (unchanged) return;
await applyUpdateDealStage(dealId, stageId, index);
}