feat(activities): let every assignee finish their own share

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-09 20:48:43 +03:30
co-authored by Cursor
parent dae3ae3f7e
commit da851bd9e5
10 changed files with 479 additions and 51 deletions
@@ -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[];
+9
View File
@@ -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')
+182 -30
View File
@@ -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 ?? [],
+8
View File
@@ -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');
+31 -4
View File
@@ -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)">
حذف
+169 -14
View File
@@ -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;
+39 -1
View File
@@ -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;
+9
View File
@@ -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 {