feat(activities): let every assignee finish their own share
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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[];
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<ActivitySummaryDto> {
|
||||
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<ActivityDto> {
|
||||
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 ?? [],
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -201,6 +201,7 @@ const page = computed(() => {
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const notice = ref('');
|
||||
const data = ref<PaginatedResponse<ActivityDto> | null>(null);
|
||||
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
|
||||
const users = ref<OrgUserDto[]>([]);
|
||||
@@ -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(() => {
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="notice" class="ok">{{ notice }}</p>
|
||||
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
|
||||
|
||||
<div v-if="selectMode" class="select-bar">
|
||||
@@ -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(() => {
|
||||
<button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)">
|
||||
انتقال به کار جاری
|
||||
</button>
|
||||
<button v-if="item.status === 'open'" type="button" @click="complete(item.id)">
|
||||
انجام شد
|
||||
<button v-if="canMarkShare(item)" type="button" @click="complete(item.id)">
|
||||
{{ completeLabel(item) }}
|
||||
</button>
|
||||
<button type="button" class="danger" @click="askDeleteActivity(item.id, item.title)">
|
||||
حذف
|
||||
@@ -1845,8 +1872,8 @@ onUnmounted(() => {
|
||||
<button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)">
|
||||
انتقال به کار جاری
|
||||
</button>
|
||||
<button v-if="item.status === 'open'" type="button" @click="complete(item.id)">
|
||||
انجام شد
|
||||
<button v-if="canMarkShare(item)" type="button" @click="complete(item.id)">
|
||||
{{ completeLabel(item) }}
|
||||
</button>
|
||||
<button type="button" class="danger" @click="askDeleteActivity(item.id, item.title)">
|
||||
حذف
|
||||
|
||||
@@ -173,16 +173,33 @@ watch(
|
||||
const users = ref<OrgUserDto[]>([]);
|
||||
const assignUserIds = ref<string[]>([]);
|
||||
|
||||
const assigneeNames = computed(() => {
|
||||
const assigneePeople = computed(() => {
|
||||
const a = activity.value;
|
||||
if (!a) return '—';
|
||||
const list = a.assignees?.length
|
||||
? a.assignees.map((u) => u.fullName)
|
||||
: a.assignee
|
||||
? [a.assignee.fullName]
|
||||
: [];
|
||||
if (!a) return [];
|
||||
if (a.assignees?.length) return a.assignees;
|
||||
return a.assignee ? [a.assignee] : [];
|
||||
});
|
||||
const assigneeNames = computed(() => {
|
||||
const list = assigneePeople.value.map((u) => u.fullName);
|
||||
return list.length ? list.join('، ') : '—';
|
||||
});
|
||||
const needsEveryone = computed(
|
||||
() =>
|
||||
activity.value?.completionMode === 'all' && assigneePeople.value.length > 1,
|
||||
);
|
||||
const completedIds = computed(() => new Set(activity.value?.completedByIds ?? []));
|
||||
const myShareDone = computed(() => {
|
||||
const mine = auth.user?.id;
|
||||
return Boolean(mine && completedIds.value.has(mine));
|
||||
});
|
||||
const canForceClose = computed(() => {
|
||||
const role = auth.user?.role;
|
||||
return (
|
||||
role === 'owner' ||
|
||||
role === 'admin' ||
|
||||
activity.value?.createdBy?.id === auth.user?.id
|
||||
);
|
||||
});
|
||||
|
||||
const activityTypes = useActivityTypes();
|
||||
const typeView = computed(() => (activity.value ? activityTypeView(activity.value) : null));
|
||||
@@ -316,15 +333,43 @@ async function loadUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
async function complete() {
|
||||
if (!id.value || isDone.value) return;
|
||||
async function complete(kind: 'share' | 'force' | 'undo' = 'share') {
|
||||
if (!id.value) return;
|
||||
if (kind !== 'undo' && isDone.value) return;
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.patch(`/activities/${id.value}/complete`);
|
||||
await router.push({ name: 'activities' });
|
||||
const params =
|
||||
kind === 'force' ? { force: '1' } : kind === 'undo' ? { undo: '1' } : undefined;
|
||||
const { data } = await api.patch<ActivityDto>(
|
||||
`/activities/${id.value}/complete`,
|
||||
undefined,
|
||||
{ params },
|
||||
);
|
||||
if (data.status === 'done') {
|
||||
await router.push({ name: 'activities' });
|
||||
return;
|
||||
}
|
||||
activity.value = data;
|
||||
} catch {
|
||||
error.value = 'ثبت انجامشدن ناموفق بود.';
|
||||
error.value =
|
||||
kind === 'undo' ? 'لغو سهم ناموفق بود.' : 'ثبت انجامشدن ناموفق بود.';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setCompletionMode(mode: 'any' | 'all') {
|
||||
if (!id.value || activity.value?.completionMode === mode) return;
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.patch<ActivityDto>(`/activities/${id.value}`, {
|
||||
completionMode: mode,
|
||||
});
|
||||
activity.value = data;
|
||||
} catch {
|
||||
error.value = 'تغییر نحوهٔ انجام ناموفق بود.';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
@@ -597,6 +642,43 @@ onMounted(() => {
|
||||
<template v-else>
|
||||
{{ assigneeNames }}
|
||||
</template>
|
||||
<div v-if="canReassign && assigneePeople.length > 1" class="share-mode">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ on: activity.completionMode !== 'all' }"
|
||||
:disabled="saving"
|
||||
@click="setCompletionMode('any')"
|
||||
>
|
||||
با انجام یکی کافی است
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ on: activity.completionMode === 'all' }"
|
||||
:disabled="saving"
|
||||
@click="setCompletionMode('all')"
|
||||
>
|
||||
همه باید انجام دهند
|
||||
</button>
|
||||
</div>
|
||||
<ul v-if="needsEveryone && !isDone" class="share-list">
|
||||
<li
|
||||
v-for="person in assigneePeople"
|
||||
:key="person.id"
|
||||
:class="{ done: completedIds.has(person.id) }"
|
||||
>
|
||||
<span>{{ person.fullName }}</span>
|
||||
<em>{{ completedIds.has(person.id) ? 'انجام شد' : 'مانده' }}</em>
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
v-if="needsEveryone && canForceClose && !isDone"
|
||||
type="button"
|
||||
class="share-force"
|
||||
:disabled="saving"
|
||||
@click="complete('force')"
|
||||
>
|
||||
بستن برای همه
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
@@ -855,15 +937,25 @@ onMounted(() => {
|
||||
<UiIcon name="clock" :size="16" />
|
||||
بازگشایی
|
||||
</button>
|
||||
<button
|
||||
v-else-if="needsEveryone && myShareDone"
|
||||
class="act primary"
|
||||
type="button"
|
||||
:disabled="saving"
|
||||
@click="complete('undo')"
|
||||
>
|
||||
<UiIcon name="refresh" :size="16" />
|
||||
لغو سهم من
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="act primary"
|
||||
type="button"
|
||||
:disabled="saving || isDone"
|
||||
@click="complete"
|
||||
@click="complete('share')"
|
||||
>
|
||||
<UiIcon name="check" :size="16" />
|
||||
انجام شد
|
||||
{{ needsEveryone ? 'انجام سهم من' : 'انجام شد' }}
|
||||
</button>
|
||||
<button class="act ghost" type="button" :disabled="saving || isDone" @click="postponeOpen = true">
|
||||
<UiIcon name="refresh" :size="16" />
|
||||
@@ -1160,6 +1252,69 @@ dd {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.share-mode {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
margin-top: 0.55rem;
|
||||
}
|
||||
|
||||
.share-mode button {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
border-radius: 999px;
|
||||
padding: 0.25rem 0.7rem;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.share-mode button.on {
|
||||
border-color: #14b8a6;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.share-list {
|
||||
list-style: none;
|
||||
margin: 0.55rem 0 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.share-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.86rem;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.share-list em {
|
||||
font-style: normal;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.share-list li.done em {
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.share-force {
|
||||
margin-top: 0.45rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #0f766e;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.flag.low,
|
||||
.priority.low {
|
||||
color: #16a34a;
|
||||
|
||||
@@ -129,6 +129,7 @@ const personId = ref('');
|
||||
const leadId = ref('');
|
||||
const dealId = ref('');
|
||||
const assigneeIds = ref<string[]>([]);
|
||||
const completionMode = ref<'any' | 'all'>('any');
|
||||
const isDone = ref(false);
|
||||
const needsNote = ref(false);
|
||||
const reminderAt = ref('');
|
||||
@@ -440,6 +441,7 @@ function resetForm() {
|
||||
personId.value = '';
|
||||
dealId.value = '';
|
||||
assigneeIds.value = auth.user?.id ? [auth.user.id] : [];
|
||||
completionMode.value = 'any';
|
||||
pickedRoleIds.value = [];
|
||||
pickedTeamIds.value = [];
|
||||
isDone.value = false;
|
||||
@@ -528,6 +530,7 @@ async function load() {
|
||||
: auth.user?.id
|
||||
? [auth.user.id]
|
||||
: [];
|
||||
completionMode.value = data.completionMode === 'all' ? 'all' : 'any';
|
||||
refreshPickedGroups();
|
||||
isDone.value = data.status === 'done';
|
||||
await loadSavedPhotos(data);
|
||||
@@ -605,6 +608,7 @@ function payload(): CreateActivityRequest {
|
||||
leadId: leadId.value || null,
|
||||
assigneeIds: [...assigneeIds.value],
|
||||
assigneeId: assigneeIds.value[0] || null,
|
||||
completionMode: assigneeIds.value.length > 1 ? completionMode.value : 'any',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -978,7 +982,9 @@ async function submit(next: 'detail' | 'new' = 'detail') {
|
||||
savedId = data.id;
|
||||
createdActivityId.value = data.id;
|
||||
if (isDone.value) {
|
||||
await api.patch(`/activities/${savedId}/complete`);
|
||||
await api.patch(`/activities/${savedId}/complete`, undefined, {
|
||||
params: { force: '1' },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await api.patch(`/activities/${savedId}`, {
|
||||
@@ -1269,6 +1275,17 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="assigneeIds.length > 1" class="completion-mode">
|
||||
<span>نحوهٔ انجام</span>
|
||||
<label>
|
||||
<input v-model="completionMode" type="radio" value="any" />
|
||||
با انجام یکی کافی است
|
||||
</label>
|
||||
<label>
|
||||
<input v-model="completionMode" type="radio" value="all" />
|
||||
همهٔ مسئولها باید انجام دهند
|
||||
</label>
|
||||
</div>
|
||||
<small class="hint">
|
||||
{{
|
||||
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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user