diff --git a/apps/api/prisma/migrations/20261103210000_activity_board_position/migration.sql b/apps/api/prisma/migrations/20261103210000_activity_board_position/migration.sql new file mode 100644 index 0000000..5a30093 --- /dev/null +++ b/apps/api/prisma/migrations/20261103210000_activity_board_position/migration.sql @@ -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"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index af53b34..030eef8 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) diff --git a/apps/api/src/activities/activities.controller.ts b/apps/api/src/activities/activities.controller.ts index ba6de59..af1e778 100644 --- a/apps/api/src/activities/activities.controller.ts +++ b/apps/api/src/activities/activities.controller.ts @@ -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') diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 31c8850..537d2ef 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -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 { 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); } diff --git a/apps/api/src/activities/dto.ts b/apps/api/src/activities/dto.ts index ef177a9..cab5131 100644 --- a/apps/api/src/activities/dto.ts +++ b/apps/api/src/activities/dto.ts @@ -219,6 +219,12 @@ export class ReorderActivityStagesDto { export class MoveActivityStageDto { @IsString() stageId!: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + position?: number; } /** محتوای fields در سرویس بر اساس تعریف نوع اعتبارسنجی می‌شود */ diff --git a/apps/api/src/common/column-order.ts b/apps/api/src/common/column-order.ts new file mode 100644 index 0000000..560412d --- /dev/null +++ b/apps/api/src/common/column-order.ts @@ -0,0 +1,28 @@ +import type { Prisma } from '@prisma/client'; + +type PositionUpdate = Prisma.PrismaPromise; + +/** کارت را در ایندکس خواسته‌شده می‌گذارد. ایندکس نسبت به فهرست بدون خود کارت است. */ +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, + 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))); + } +} diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts index fff017e..d266139 100644 --- a/apps/api/src/leads/leads.service.ts +++ b/apps/api/src/leads/leads.service.ts @@ -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 }, diff --git a/apps/api/src/pipelines/deals.service.ts b/apps/api/src/pipelines/deals.service.ts index 7528e57..68df5ac 100644 --- a/apps/api/src/pipelines/deals.service.ts +++ b/apps/api/src/pipelines/deals.service.ts @@ -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, diff --git a/apps/web/src/components/activities/ActivitiesKanban.vue b/apps/web/src/components/activities/ActivitiesKanban.vue index d87a2bc..d939153 100644 --- a/apps/web/src/components/activities/ActivitiesKanban.vue +++ b/apps/web/src/components/activities/ActivitiesKanban.vue @@ -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(`/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; diff --git a/apps/web/src/components/pipeline/DealCard.vue b/apps/web/src/components/pipeline/DealCard.vue index 95a18a1..ff850d4 100644 --- a/apps/web/src/components/pipeline/DealCard.vue +++ b/apps/web/src/components/pipeline/DealCard.vue @@ -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 = { + 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(() => ({ + + + + + ; + /** اولویت فعالیت؛ فقط روی کارت فعالیت نشان داده می‌شود */ + 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, diff --git a/apps/web/src/views/LeadBoardView.vue b/apps/web/src/views/LeadBoardView.vue index b02c80a..570d21f 100644 --- a/apps/web/src/views/LeadBoardView.vue +++ b/apps/web/src/views/LeadBoardView.vue @@ -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); مسیر لیدها - کشیدن کارت برای تغییر وضعیت. از منوی هر ستون می‌توانید نام، ترتیب و حذف را عوض کنید. + کشیدن کارت برای تغییر ستون یا جابه‌جایی داخل همان ستون. از منوی هر ستون می‌توانید نام، ترتیب و حذف را عوض کنید. @@ -647,9 +657,13 @@ onMounted(load);
- + +
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); }