fix(boards): keep card order inside a column and show activity priority
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "Activity" ADD COLUMN "boardPosition" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX "Activity_stageId_boardPosition_idx" ON "Activity"("stageId", "boardPosition");
|
||||
@@ -896,6 +896,8 @@ model Activity {
|
||||
/// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهمنوع با status → اولین مرحلهٔ همنوع
|
||||
stageId String?
|
||||
stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull)
|
||||
/// ترتیب کارت داخل همان ستون کانبان
|
||||
boardPosition Int @default(0)
|
||||
/// نوع سفارشی؛ null → نوع اصلیِ همان type
|
||||
typeDefId String?
|
||||
typeDef ActivityTypeDef? @relation(fields: [typeDefId], references: [id], onDelete: SetNull)
|
||||
@@ -911,6 +913,7 @@ model Activity {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([stageId])
|
||||
@@index([stageId, boardPosition])
|
||||
@@index([boardId])
|
||||
@@index([typeDefId])
|
||||
@@index([leadId])
|
||||
|
||||
@@ -428,7 +428,13 @@ export class ActivitiesController {
|
||||
@Param('id') id: string,
|
||||
@Body() dto: MoveActivityStageDto,
|
||||
) {
|
||||
return this.activitiesService.moveToStage(user.organizationId, id, user, dto.stageId);
|
||||
return this.activitiesService.moveToStage(
|
||||
user.organizationId,
|
||||
id,
|
||||
user,
|
||||
dto.stageId,
|
||||
dto.position,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':id/complete')
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
ownerWhere,
|
||||
} from '../common/record-scope';
|
||||
import { assertAssigneeSet } from '../common/assign-access';
|
||||
import { placeId, sameIds, writePositions } from '../common/column-order';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
import { tehranDayKey } from '../routines/routine-dates';
|
||||
@@ -329,9 +330,7 @@ export class ActivitiesService {
|
||||
where,
|
||||
include,
|
||||
// روی پروندهٔ لید، تازهترین (از جمله پیگیری آینده) اول بیاید تا از سقف صفحه نیفتد
|
||||
orderBy: entity?.leadId
|
||||
? [{ createdAt: 'desc' }]
|
||||
: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
|
||||
orderBy: this.listOrder(entity, stageScope),
|
||||
skip: (page - 1) * take,
|
||||
take,
|
||||
}),
|
||||
@@ -385,7 +384,7 @@ export class ActivitiesService {
|
||||
this.prisma.activity.findMany({
|
||||
where,
|
||||
select: { id: true },
|
||||
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
|
||||
orderBy: this.listOrder(entity, stageScope),
|
||||
take: cap,
|
||||
}),
|
||||
this.prisma.activity.count({ where }),
|
||||
@@ -490,11 +489,46 @@ export class ActivitiesService {
|
||||
return { counts, total };
|
||||
}
|
||||
|
||||
private listOrder(
|
||||
entity?: { leadId?: string },
|
||||
stageScope?: StageScope,
|
||||
): Prisma.ActivityOrderByWithRelationInput[] {
|
||||
if (entity?.leadId) return [{ createdAt: 'desc' }];
|
||||
if (stageScope && stageScope.stageId !== 'all') {
|
||||
return [{ boardPosition: 'asc' }, { dueAt: 'asc' }, { createdAt: 'desc' }];
|
||||
}
|
||||
return [{ dueAt: 'asc' }, { createdAt: 'desc' }];
|
||||
}
|
||||
|
||||
private async reorderStage(
|
||||
organizationId: string,
|
||||
stageId: string,
|
||||
activityId: string,
|
||||
index: number,
|
||||
) {
|
||||
const where = await this.stages.whereForStage(organizationId, stageId);
|
||||
const rows = await this.prisma.activity.findMany({
|
||||
where,
|
||||
select: { id: true },
|
||||
orderBy: [{ boardPosition: 'asc' }, { dueAt: 'asc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
const currentIds = rows.map((row) => row.id);
|
||||
const placed = placeId(currentIds, activityId, index);
|
||||
if (sameIds(currentIds, placed)) return;
|
||||
await writePositions(
|
||||
(ops) => this.prisma.$transaction(ops),
|
||||
placed,
|
||||
(id, boardPosition) =>
|
||||
this.prisma.activity.update({ where: { id }, data: { boardPosition } }),
|
||||
);
|
||||
}
|
||||
|
||||
async moveToStage(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: ActivityActor,
|
||||
stageId: string,
|
||||
position?: number,
|
||||
): Promise<ActivityDto> {
|
||||
await this.ensureExists(organizationId, id, actor);
|
||||
const stage = await this.stages.get(organizationId, stageId);
|
||||
@@ -519,6 +553,7 @@ export class ActivitiesService {
|
||||
},
|
||||
include,
|
||||
});
|
||||
if (position != null) await this.reorderStage(organizationId, stage.id, id, position);
|
||||
if (stage.kind === 'done' && existing?.status !== 'done') {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
|
||||
@@ -219,6 +219,12 @@ export class ReorderActivityStagesDto {
|
||||
export class MoveActivityStageDto {
|
||||
@IsString()
|
||||
stageId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
position?: number;
|
||||
}
|
||||
|
||||
/** محتوای fields در سرویس بر اساس تعریف نوع اعتبارسنجی میشود */
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
type PositionUpdate = Prisma.PrismaPromise<unknown>;
|
||||
|
||||
/** کارت را در ایندکس خواستهشده میگذارد. ایندکس نسبت به فهرست بدون خود کارت است. */
|
||||
export function placeId(ids: string[], movingId: string, index: number): string[] {
|
||||
const next = ids.filter((id) => id !== movingId);
|
||||
const at = Math.max(0, Math.min(Math.trunc(index), next.length));
|
||||
next.splice(at, 0, movingId);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function sameIds(left: string[], right: string[]): boolean {
|
||||
return left.length === right.length && left.every((id, index) => id === right[index]);
|
||||
}
|
||||
|
||||
/** ترتیب ستون را ۰ تا n ذخیره میکند. */
|
||||
export async function writePositions(
|
||||
transaction: (ops: PositionUpdate[]) => Promise<unknown>,
|
||||
ids: string[],
|
||||
update: (id: string, position: number) => PositionUpdate,
|
||||
) {
|
||||
const size = 60;
|
||||
for (let start = 0; start < ids.length; start += size) {
|
||||
const slice = ids.slice(start, start + size);
|
||||
await transaction(slice.map((id, offset) => update(id, start + offset)));
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
forScope,
|
||||
} from '../common/record-scope';
|
||||
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
||||
import { placeId, sameIds, writePositions } from '../common/column-order';
|
||||
import {
|
||||
asAddresses,
|
||||
collectAddresses,
|
||||
@@ -613,24 +614,43 @@ export class LeadsService {
|
||||
});
|
||||
if (!stage) throw new NotFoundException('مرحله پیدا نشد');
|
||||
|
||||
let placedIds: string[] | null = null;
|
||||
let nextPosition = position ?? 0;
|
||||
if (position != null) {
|
||||
const rows = await this.prisma.lead.findMany({
|
||||
where: { organizationId: actor.organizationId, stageId, archivedAt: null },
|
||||
select: { id: true },
|
||||
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
|
||||
});
|
||||
const currentIds = rows.map((row) => row.id);
|
||||
placedIds = placeId(currentIds, id, position);
|
||||
nextPosition = Math.max(0, placedIds.indexOf(id));
|
||||
if (sameIds(currentIds, placedIds)) placedIds = null;
|
||||
}
|
||||
|
||||
const row = await this.prisma.lead.update({
|
||||
where: { id },
|
||||
data: {
|
||||
stageId,
|
||||
position: position ?? 0,
|
||||
...(stage.isConverted
|
||||
? {}
|
||||
: {}),
|
||||
position: nextPosition,
|
||||
},
|
||||
include: leadInclude,
|
||||
});
|
||||
await this.addEvent(
|
||||
actor,
|
||||
id,
|
||||
'stage_changed',
|
||||
`به مرحله «${stage.name}» منتقل شد`,
|
||||
);
|
||||
if (placedIds) {
|
||||
await writePositions(
|
||||
(ops) => this.prisma.$transaction(ops),
|
||||
placedIds,
|
||||
(leadId, leadPosition) =>
|
||||
this.prisma.lead.update({ where: { id: leadId }, data: { position: leadPosition } }),
|
||||
);
|
||||
}
|
||||
if (before.stageId !== stageId) {
|
||||
await this.addEvent(
|
||||
actor,
|
||||
id,
|
||||
'stage_changed',
|
||||
`به مرحله «${stage.name}» منتقل شد`,
|
||||
);
|
||||
this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, {
|
||||
eventKey: `${id}:${stageId}`,
|
||||
facts: { stageName: stage.name, source: row.source },
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
resolveOwnerId,
|
||||
} from '../common/record-scope';
|
||||
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
||||
import { placeId, sameIds, writePositions } from '../common/column-order';
|
||||
|
||||
const dealInclude = {
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
@@ -379,7 +380,8 @@ export class DealsService {
|
||||
}
|
||||
|
||||
const status = stage.isWon ? 'won' : stage.isLost ? 'lost' : 'open';
|
||||
const position =
|
||||
let placedIds: string[] | null = null;
|
||||
let position =
|
||||
dto.position ??
|
||||
((
|
||||
await this.prisma.deal.aggregate({
|
||||
@@ -387,6 +389,17 @@ export class DealsService {
|
||||
_max: { position: true },
|
||||
})
|
||||
)._max.position ?? -1) + 1;
|
||||
if (dto.position != null) {
|
||||
const rows = await this.prisma.deal.findMany({
|
||||
where: { organizationId, stageId: stage.id },
|
||||
select: { id: true },
|
||||
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
|
||||
});
|
||||
const currentIds = rows.map((row) => row.id);
|
||||
placedIds = placeId(currentIds, deal.id, dto.position);
|
||||
position = Math.max(0, placedIds.indexOf(deal.id));
|
||||
if (sameIds(currentIds, placedIds)) placedIds = null;
|
||||
}
|
||||
|
||||
const row = await this.prisma.deal.update({
|
||||
where: { id },
|
||||
@@ -402,6 +415,15 @@ export class DealsService {
|
||||
include: dealInclude,
|
||||
});
|
||||
|
||||
if (placedIds) {
|
||||
await writePositions(
|
||||
(ops) => this.prisma.$transaction(ops),
|
||||
placedIds,
|
||||
(dealId, nextPosition) =>
|
||||
this.prisma.deal.update({ where: { id: dealId }, data: { position: nextPosition } }),
|
||||
);
|
||||
}
|
||||
|
||||
if (deal.ownerId && deal.stageId !== stage.id) {
|
||||
await this.notifications.create({
|
||||
organizationId,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user