diff --git a/apps/api/prisma/migrations/20261109170000_activity_completion_mode/migration.sql b/apps/api/prisma/migrations/20261109170000_activity_completion_mode/migration.sql new file mode 100644 index 0000000..9e44bab --- /dev/null +++ b/apps/api/prisma/migrations/20261109170000_activity_completion_mode/migration.sql @@ -0,0 +1,6 @@ +-- CreateEnum +CREATE TYPE "ActivityCompletionMode" AS ENUM ('any', 'all'); + +-- AlterTable +ALTER TABLE "Activity" ADD COLUMN "completionMode" "ActivityCompletionMode" NOT NULL DEFAULT 'any', +ADD COLUMN "completedByIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index d78e2bb..05ebe03 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -33,6 +33,11 @@ enum ActivityStatus { canceled } +enum ActivityCompletionMode { + any + all +} + enum ActivityPriority { low medium @@ -883,6 +888,10 @@ model Activity { /// زمان ارسال یادآور؛ با تغییر reminderAt پاک می‌شود reminderSentAt DateTime? completedAt DateTime? + /// any = با انجام یکی بسته می‌شود؛ all = هر مسئول باید سهم خودش را بزند + completionMode ActivityCompletionMode @default(any) + /// مسئولانی که سهم خود را در حالت all ثبت کرده‌اند + completedByIds String[] @default([]) tags String[] @default([]) personId String? person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) diff --git a/apps/api/src/activities/activities.controller.ts b/apps/api/src/activities/activities.controller.ts index af1e778..d8f161f 100644 --- a/apps/api/src/activities/activities.controller.ts +++ b/apps/api/src/activities/activities.controller.ts @@ -438,8 +438,16 @@ export class ActivitiesController { } @Patch(':id/complete') - complete(@CurrentUser() user: Actor, @Param('id') id: string) { - return this.activitiesService.complete(user.organizationId, id, user); + complete( + @CurrentUser() user: Actor, + @Param('id') id: string, + @Query('force') force?: string, + @Query('undo') undo?: string, + ) { + return this.activitiesService.complete(user.organizationId, id, user, { + force: force === '1' || force === 'true', + undo: undo === '1' || undo === 'true', + }); } @Delete(':id') diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 537d2ef..4c235cc 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -52,6 +52,28 @@ type ActivityActor = Pick< 'userId' | 'role' | 'priorLens' | 'priorCutoff' | 'leadsAccess' | 'teamPeerIds' >; +function assigneeIdList(row: { + assigneeId?: string | null; + assignees?: Array<{ id: string }>; +}): string[] { + const ids = row.assignees?.map((item) => item.id) ?? []; + if (ids.length) return [...new Set(ids)]; + return row.assigneeId ? [row.assigneeId] : []; +} + +function canForceClose( + actor: { userId: string; role: string }, + createdById: string | null | undefined, +): boolean { + return actor.role === 'owner' || actor.role === 'admin' || actor.userId === createdById; +} + +function reconcileAllShares(assigneeIds: string[], completedByIds: string[]) { + const kept = [...new Set(completedByIds.filter((id) => assigneeIds.includes(id)))]; + const done = assigneeIds.length > 0 && assigneeIds.every((id) => kept.includes(id)); + return { status: done ? ('done' as const) : ('open' as const), completedByIds: kept }; +} + /** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمی‌کند (جز عقب‌افتاده) */ type StageScope = { stageId: string }; @@ -82,6 +104,8 @@ type ActivityRow = { reminderNotifySms: boolean; reminderSentAt: Date | null; completedAt: Date | null; + completionMode?: 'any' | 'all'; + completedByIds?: string[]; importSource?: string; promotedFromPriorAt?: Date | null; tags: string[]; @@ -186,22 +210,39 @@ export class ActivitiesService { return { stages, types }; } + /** کارهای بازِ «من» که سهم‌شان ثبت شده، از میز کار شخص خارج می‌شوند. */ + private deskScope( + actor: ActivityActor, + desk?: 'mine' | 'team', + ): Prisma.ActivityWhereInput { + if (desk !== 'mine') return activityAccessWhere(actor); + return { + AND: [ + { + OR: [ + { assigneeId: actor.userId }, + { assignees: { some: { id: actor.userId } } }, + { participantIds: { has: actor.userId } }, + ], + }, + { + NOT: { + completionMode: 'all', + status: 'open', + completedByIds: { has: actor.userId }, + }, + }, + ], + }; + } + async summary( organizationId: string, actor: ActivityActor, desk?: 'mine' | 'team', ): Promise { const { start, end } = dayBounds(); - const scope: Prisma.ActivityWhereInput = - desk === 'mine' - ? { - OR: [ - { assigneeId: actor.userId }, - { assignees: { some: { id: actor.userId } } }, - { participantIds: { has: actor.userId } }, - ], - } - : activityAccessWhere(actor); + const scope = this.deskScope(actor, desk); const prior = activityPriorWhere(actor.priorLens, actor.priorCutoff); const [todayCount, overdueCount] = await Promise.all([ this.prisma.activity.count({ @@ -534,8 +575,24 @@ export class ActivitiesService { const stage = await this.stages.get(organizationId, stageId); const existing = await this.prisma.activity.findFirst({ where: { id, organizationId }, - select: { status: true, completedAt: true }, + select: { + status: true, + completedAt: true, + completionMode: true, + completedByIds: true, + createdById: true, + assigneeId: true, + assignees: { select: { id: true } }, + }, }); + if ( + stage.kind === 'done' && + existing?.completionMode === 'all' && + assigneeIdList(existing).length > 1 && + !canForceClose(actor, existing.createdById) + ) { + return this.complete(organizationId, id, actor); + } const row = await this.prisma.activity.update({ where: { id }, data: { @@ -550,6 +607,9 @@ export class ActivitiesService { : stage.kind === 'open' ? null : undefined, + ...(stage.kind === 'open' && existing?.status !== 'open' + ? { completedByIds: [] } + : {}), }, include, }); @@ -675,6 +735,8 @@ export class ActivitiesService { assignees: assigneeIds.length ? { connect: assigneeIds.map((id) => ({ id })) } : undefined, + completionMode: dto.completionMode === 'all' ? 'all' : 'any', + completedByIds: [], createdById: userId, }, include, @@ -743,7 +805,17 @@ export class ActivitiesService { const existing = await this.prisma.activity.findFirst({ where: { id, organizationId }, - select: { status: true, type: true, typeDefId: true, fields: true, participantIds: true }, + select: { + status: true, + type: true, + typeDefId: true, + fields: true, + participantIds: true, + completionMode: true, + completedByIds: true, + assigneeId: true, + assignees: { select: { id: true } }, + }, }); const data: Prisma.ActivityUncheckedUpdateInput = {}; if (dto.title !== undefined) data.title = dto.title.trim(); @@ -812,6 +884,7 @@ export class ActivitiesService { if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null); if ('leadId' in dto) data.leadId = emptyToNull(dto.leadId ?? null); let assigneeConnect: Array<{ id: string }> | undefined; + let nextAssigneeIds: string[] | undefined; if (dto.assigneeIds !== undefined || 'assigneeId' in dto) { const current = await this.prisma.activity.findFirst({ where: { id, organizationId }, @@ -839,6 +912,7 @@ export class ActivitiesService { ); data.assigneeId = assigneeIds[0]!; assigneeConnect = assigneeIds.map((uid) => ({ id: uid })); + nextAssigneeIds = assigneeIds; } else { const assigneeId = emptyToNull(dto.assigneeId ?? null); if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId); @@ -852,12 +926,41 @@ export class ActivitiesService { data.assigneeId = assigneeId; if (assigneeId) assigneeConnect = [{ id: assigneeId }]; else assigneeConnect = []; + nextAssigneeIds = assigneeId ? [assigneeId] : []; } } - if (dto.status !== undefined) { - data.status = dto.status; - data.completedAt = - dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined; + const modeChanged = dto.completionMode === 'any' || dto.completionMode === 'all'; + const nextMode = modeChanged ? dto.completionMode! : (existing?.completionMode ?? 'any'); + if (modeChanged) data.completionMode = nextMode; + const prevAssigneeIds = assigneeIdList(existing ?? {}); + const nextIds = nextAssigneeIds ?? prevAssigneeIds; + const explicitDone = dto.status === 'done'; + const explicitReopen = dto.status === 'open' && existing?.status !== 'open'; + const explicitCancel = dto.status === 'canceled'; + if (explicitDone) { + data.status = 'done'; + data.completedAt = new Date(); + } else if (explicitReopen) { + data.status = 'open'; + data.completedAt = null; + data.completedByIds = []; + } else if (explicitCancel) { + data.status = 'canceled'; + } else if (nextMode === 'any' && modeChanged) { + data.completedByIds = []; + } else if (nextMode === 'all' && (modeChanged || nextAssigneeIds)) { + const reconciled = reconcileAllShares(nextIds, existing?.completedByIds ?? []); + data.completedByIds = reconciled.completedByIds; + const finishedStaysFinished = existing?.status === 'done' && !nextAssigneeIds; + if (existing?.status !== 'canceled' && !finishedStaysFinished) { + if (reconciled.status === 'done' && existing?.status !== 'done') { + data.status = 'done'; + data.completedAt = new Date(); + } else if (reconciled.status === 'open' && existing?.status === 'done') { + data.status = 'open'; + data.completedAt = null; + } + } } const row = await this.prisma.activity.update({ @@ -881,7 +984,7 @@ export class ActivitiesService { addedParticipants.filter((uid) => !assigneeSet.has(uid)), ); } - if (dto.status === 'done' && existing?.status !== 'done') { + if (data.status === 'done' && existing?.status !== 'done') { await this.fireDone(organizationId, row); } return this.toDto(row, await this.ctx(organizationId)); @@ -891,18 +994,74 @@ export class ActivitiesService { organizationId: string, id: string, actor: ActivityActor, + options: { force?: boolean; undo?: boolean } = {}, ): Promise { await this.ensureExists(organizationId, id, actor); const existing = await this.prisma.activity.findFirst({ where: { id, organizationId }, - select: { status: true }, + select: { + status: true, + completionMode: true, + completedByIds: true, + createdById: true, + assigneeId: true, + assignees: { select: { id: true } }, + }, }); + if (!existing) throw new NotFoundException('فعالیت پیدا نشد'); + if (existing.status === 'canceled') { + throw new BadRequestException('فعالیت لغو شده است'); + } + + const assignees = assigneeIdList(existing); + const allowedForce = canForceClose(actor, existing.createdById); + if (options.force && !allowedForce) { + throw new ForbiddenException('بستن فعالیت برای همه فقط برای سازنده یا مدیر ممکن است'); + } + + if (options.undo) { + if (existing.completionMode !== 'all' || existing.status !== 'open') { + throw new BadRequestException('سهم انجام‌شده را فقط تا وقتی فعالیت باز است می‌توان لغو کرد'); + } + const row = await this.prisma.activity.update({ + where: { id }, + data: { + completedByIds: existing.completedByIds.filter((uid) => uid !== actor.userId), + }, + include, + }); + return this.toDto(row, await this.ctx(organizationId)); + } + + const personal = + existing.completionMode === 'all' && assignees.length > 1 && !options.force; + if (!personal) { + const row = await this.prisma.activity.update({ + where: { id }, + data: { status: 'done', completedAt: new Date() }, + include, + }); + if (existing.status !== 'done') await this.fireDone(organizationId, row); + return this.toDto(row, await this.ctx(organizationId)); + } + + if (!assignees.includes(actor.userId)) { + throw new ForbiddenException('فقط مسئول این فعالیت می‌تواند سهم خودش را ثبت کند'); + } + const reconciled = reconcileAllShares(assignees, [ + ...existing.completedByIds, + actor.userId, + ]); const row = await this.prisma.activity.update({ where: { id }, - data: { status: 'done', completedAt: new Date() }, + data: { + completedByIds: reconciled.completedByIds, + status: reconciled.status, + completedAt: reconciled.status === 'done' ? new Date() : null, + }, include, }); - if (existing?.status !== 'done') { + if (reconciled.status === 'done' && existing.status !== 'done') { await this.fireDone(organizationId, row); } return this.toDto(row, await this.ctx(organizationId)); @@ -1108,16 +1267,7 @@ export class ActivitiesService { typeRows: TypeRow[] = [], ): Prisma.ActivityWhereInput { const { start, end } = dayBounds(); - const scope: Prisma.ActivityWhereInput = - desk === 'mine' - ? { - OR: [ - { assigneeId: actor.userId }, - { assignees: { some: { id: actor.userId } } }, - { participantIds: { has: actor.userId } }, - ], - } - : activityAccessWhere(actor); + const scope = this.deskScope(actor, desk); const facetAssignee = facets?.assigneeId?.trim(); const facetTypeDef = facets?.typeDefId?.trim(); const facetTitle = facets?.title?.trim(); @@ -1418,6 +1568,8 @@ export class ActivitiesService { reminderNotifySms: row.reminderNotifySms, reminderSentAt: row.reminderSentAt?.toISOString() ?? null, completedAt: row.completedAt?.toISOString() ?? null, + completionMode: row.completionMode ?? 'any', + completedByIds: row.completedByIds ?? [], importSource: row.importSource ?? 'manual', promotedFromPriorAt: row.promotedFromPriorAt?.toISOString() ?? null, tags: row.tags ?? [], diff --git a/apps/api/src/activities/dto.ts b/apps/api/src/activities/dto.ts index cab5131..0d80d7b 100644 --- a/apps/api/src/activities/dto.ts +++ b/apps/api/src/activities/dto.ts @@ -384,6 +384,10 @@ export class CreateActivityDto { @IsArray() @IsString({ each: true }) assigneeIds?: string[]; + + @IsOptional() + @IsIn(['any', 'all']) + completionMode?: 'any' | 'all'; } export class UpdateActivityDto { @@ -484,4 +488,8 @@ export class UpdateActivityDto { @IsArray() @IsString({ each: true }) assigneeIds?: string[]; + + @IsOptional() + @IsIn(['any', 'all']) + completionMode?: 'any' | 'all'; } diff --git a/apps/web/src/components/activities/ActivitiesKanban.vue b/apps/web/src/components/activities/ActivitiesKanban.vue index d939153..20d7d11 100644 --- a/apps/web/src/components/activities/ActivitiesKanban.vue +++ b/apps/web/src/components/activities/ActivitiesKanban.vue @@ -41,6 +41,7 @@ const emit = defineEmits<{ stagesChanged: []; add: [stageId: string]; error: [message: string]; + notice: [message: string]; menuDeal: [activity: ActivityDto, stageId?: string]; toggleSelect: [activityId: string]; bulkDelete: []; @@ -292,6 +293,21 @@ async function moveCard(activityId: string, toStageId: string, index?: number) { 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'); diff --git a/apps/web/src/views/ActivitiesView.vue b/apps/web/src/views/ActivitiesView.vue index d44c8fc..a1ff255 100644 --- a/apps/web/src/views/ActivitiesView.vue +++ b/apps/web/src/views/ActivitiesView.vue @@ -201,6 +201,7 @@ const page = computed(() => { const loading = ref(false); const error = ref(''); +const notice = ref(''); const data = ref | null>(null); const q = ref(typeof route.query.q === 'string' ? route.query.q : ''); const users = ref([]); @@ -837,6 +838,30 @@ function onFacetDone() { openCol.value = null; } +function assigneePeopleOf(item: ActivityDto) { + if (item.assignees?.length) return item.assignees; + return item.assignee ? [item.assignee] : []; +} + +function needsEveryone(item: ActivityDto) { + return item.completionMode === 'all' && assigneePeopleOf(item).length > 1; +} + +function canMarkShare(item: ActivityDto) { + if (item.status !== 'open') return false; + if (!needsEveryone(item)) return true; + const mine = auth.user?.id; + if (!mine) return false; + return ( + assigneePeopleOf(item).some((person) => person.id === mine) && + !item.completedByIds?.includes(mine) + ); +} + +function completeLabel(item: ActivityDto) { + return needsEveryone(item) ? 'انجام سهم من' : 'انجام شد'; +} + async function complete(id: string) { menuId.value = null; boardMenuId.value = null; @@ -1503,6 +1528,7 @@ onUnmounted(() => {

{{ error }}

+

{{ notice }}

{{ importMsg }}

@@ -1532,6 +1558,7 @@ onUnmounted(() => { @stages-changed="onStagesChanged" @add="openAddInStage" @error="(msg: string) => (error = msg)" + @notice="(msg: string) => (notice = msg)" @menu-deal="onBoardMenu" @toggle-select="toggleSelect" @bulk-delete="askBulkDelete" @@ -1755,8 +1782,8 @@ onUnmounted(() => { - - + +
+ +
@@ -855,15 +937,25 @@ onMounted(() => { بازگشایی +
+
+ نحوهٔ انجام + + +
{{ canAssignOthers @@ -1799,6 +1816,27 @@ watch([id, isNew], () => void load(), { immediate: true }); font-size: 0.75rem; } +.completion-mode { + display: flex; + flex-wrap: wrap; + gap: 0.65rem 1rem; + margin-top: 0.75rem; + color: #334155; + font-size: 0.88rem; +} + +.completion-mode span { + width: 100%; + font-weight: 600; +} + +.completion-mode label { + display: inline-flex; + align-items: center; + gap: 0.35rem; + cursor: pointer; +} + .assignee-chips { display: flex; flex-wrap: wrap; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a88e449..b77e2ee 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2038,6 +2038,8 @@ export interface ActivityImageDto { createdAt: string; } +export type ActivityCompletionMode = "any" | "all"; + export interface ActivityDto { id: string; type: ActivityType; @@ -2055,6 +2057,10 @@ export interface ActivityDto { /** زمان ارسال یادآور (اگر ارسال شده باشد) */ reminderSentAt: string | null; completedAt: string | null; + /** any = با انجام یکی کافی است؛ all = هر مسئول باید سهم خودش را بزند */ + completionMode?: ActivityCompletionMode; + /** شناسهٔ مسئولانی که سهم خود را در حالت all ثبت کرده‌اند */ + completedByIds?: string[]; /** manual یا didar؛ برای جدا کردن سابقهٔ قبلی */ importSource?: string; promotedFromPriorAt?: string | null; @@ -2112,6 +2118,8 @@ export interface CreateActivityRequest { assigneeId?: string | null; /** چند مسئول؛ اولی به‌عنوان assigneeId ذخیره می‌شود */ assigneeIds?: string[]; + /** پیش‌فرض any. فقط وقتی چند مسئول دارد معنی دارد */ + completionMode?: ActivityCompletionMode; } export interface UpdateActivityRequest { @@ -2136,6 +2144,7 @@ export interface UpdateActivityRequest { leadId?: string | null; assigneeId?: string | null; assigneeIds?: string[]; + completionMode?: ActivityCompletionMode; } export interface ActivitySummaryDto {