feat(activities): let creators lock edit and delete to themselves
Add editPolicy (default creator-only), enforce it in the API, surface clear permission messages, and drop the redundant edit menu now that detail is editable. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ActivityEditPolicy" AS ENUM ('creator', 'assignees');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Activity" ADD COLUMN "editPolicy" "ActivityEditPolicy" NOT NULL DEFAULT 'creator';
|
||||
@@ -38,6 +38,12 @@ enum ActivityCompletionMode {
|
||||
all
|
||||
}
|
||||
|
||||
/// چه کسانی میتوانند فعالیت را ویرایش/حذف کنند (علاوه بر owner/admin)
|
||||
enum ActivityEditPolicy {
|
||||
creator
|
||||
assignees
|
||||
}
|
||||
|
||||
enum ActivityPriority {
|
||||
low
|
||||
medium
|
||||
@@ -892,6 +898,8 @@ model Activity {
|
||||
completionMode ActivityCompletionMode @default(any)
|
||||
/// مسئولانی که سهم خود را در حالت all ثبت کردهاند
|
||||
completedByIds String[] @default([])
|
||||
/// creator = فقط ایجادکننده؛ assignees = ایجادکننده + همهٔ مسئولان
|
||||
editPolicy ActivityEditPolicy @default(creator)
|
||||
tags String[] @default([])
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@ -68,6 +68,21 @@ function canForceClose(
|
||||
return actor.role === 'owner' || actor.role === 'admin' || actor.userId === createdById;
|
||||
}
|
||||
|
||||
function canMutateActivity(
|
||||
actor: { userId: string; role: string },
|
||||
row: {
|
||||
createdById?: string | null;
|
||||
editPolicy?: 'creator' | 'assignees' | null;
|
||||
assigneeId?: string | null;
|
||||
assignees?: Array<{ id: string }>;
|
||||
},
|
||||
): boolean {
|
||||
if (actor.role === 'owner' || actor.role === 'admin') return true;
|
||||
if (row.createdById && actor.userId === row.createdById) return true;
|
||||
if ((row.editPolicy ?? 'creator') !== 'assignees') return false;
|
||||
return assigneeIdList(row).includes(actor.userId);
|
||||
}
|
||||
|
||||
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));
|
||||
@@ -737,6 +752,7 @@ export class ActivitiesService {
|
||||
: undefined,
|
||||
completionMode: dto.completionMode === 'all' ? 'all' : 'any',
|
||||
completedByIds: [],
|
||||
editPolicy: dto.editPolicy === 'assignees' ? 'assignees' : 'creator',
|
||||
createdById: userId,
|
||||
},
|
||||
include,
|
||||
@@ -813,10 +829,15 @@ export class ActivitiesService {
|
||||
participantIds: true,
|
||||
completionMode: true,
|
||||
completedByIds: true,
|
||||
editPolicy: true,
|
||||
createdById: true,
|
||||
assigneeId: true,
|
||||
assignees: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
if (!existing || !canMutateActivity(actor, existing)) {
|
||||
throw new ForbiddenException('شما مجوز ویرایش این فعالیت را ندارید');
|
||||
}
|
||||
const data: Prisma.ActivityUncheckedUpdateInput = {};
|
||||
if (dto.title !== undefined) data.title = dto.title.trim();
|
||||
if ('boardId' in dto) {
|
||||
@@ -932,6 +953,9 @@ export class ActivitiesService {
|
||||
const modeChanged = dto.completionMode === 'any' || dto.completionMode === 'all';
|
||||
const nextMode = modeChanged ? dto.completionMode! : (existing?.completionMode ?? 'any');
|
||||
if (modeChanged) data.completionMode = nextMode;
|
||||
if (dto.editPolicy === 'creator' || dto.editPolicy === 'assignees') {
|
||||
data.editPolicy = dto.editPolicy;
|
||||
}
|
||||
const prevAssigneeIds = assigneeIdList(existing ?? {});
|
||||
const nextIds = nextAssigneeIds ?? prevAssigneeIds;
|
||||
const explicitDone = dto.status === 'done';
|
||||
@@ -1105,6 +1129,18 @@ export class ActivitiesService {
|
||||
actor: ActivityActor,
|
||||
): Promise<void> {
|
||||
await this.ensureExists(organizationId, id, actor);
|
||||
const existing = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: {
|
||||
createdById: true,
|
||||
editPolicy: true,
|
||||
assigneeId: true,
|
||||
assignees: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
if (!existing || !canMutateActivity(actor, existing)) {
|
||||
throw new ForbiddenException('شما مجوز حذف این فعالیت را ندارید');
|
||||
}
|
||||
const images = await this.prisma.activityImage.findMany({
|
||||
where: { activityId: id, organizationId },
|
||||
select: { storageKey: true, sizeBytes: true },
|
||||
@@ -1125,6 +1161,18 @@ export class ActivitiesService {
|
||||
actor: ActivityActor,
|
||||
): Promise<ActivityImageDto> {
|
||||
await this.ensureExists(organizationId, activityId, actor);
|
||||
const gate = await this.prisma.activity.findFirst({
|
||||
where: { id: activityId, organizationId },
|
||||
select: {
|
||||
createdById: true,
|
||||
editPolicy: true,
|
||||
assigneeId: true,
|
||||
assignees: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
if (!gate || !canMutateActivity(actor, gate)) {
|
||||
throw new ForbiddenException('شما مجوز ویرایش این فعالیت را ندارید');
|
||||
}
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('فایلی ارسال نشده است');
|
||||
}
|
||||
@@ -1219,6 +1267,18 @@ export class ActivitiesService {
|
||||
actor: ActivityActor,
|
||||
): Promise<void> {
|
||||
await this.ensureExists(organizationId, activityId, actor);
|
||||
const gate = await this.prisma.activity.findFirst({
|
||||
where: { id: activityId, organizationId },
|
||||
select: {
|
||||
createdById: true,
|
||||
editPolicy: true,
|
||||
assigneeId: true,
|
||||
assignees: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
if (!gate || !canMutateActivity(actor, gate)) {
|
||||
throw new ForbiddenException('شما مجوز ویرایش این فعالیت را ندارید');
|
||||
}
|
||||
const row = await this.prisma.activityImage.findFirst({
|
||||
where: { id: imageId, activityId, organizationId },
|
||||
});
|
||||
@@ -1570,6 +1630,7 @@ export class ActivitiesService {
|
||||
completedAt: row.completedAt?.toISOString() ?? null,
|
||||
completionMode: row.completionMode ?? 'any',
|
||||
completedByIds: row.completedByIds ?? [],
|
||||
editPolicy: row.editPolicy ?? 'creator',
|
||||
importSource: row.importSource ?? 'manual',
|
||||
promotedFromPriorAt: row.promotedFromPriorAt?.toISOString() ?? null,
|
||||
tags: row.tags ?? [],
|
||||
|
||||
@@ -388,6 +388,10 @@ export class CreateActivityDto {
|
||||
@IsOptional()
|
||||
@IsIn(['any', 'all'])
|
||||
completionMode?: 'any' | 'all';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['creator', 'assignees'])
|
||||
editPolicy?: 'creator' | 'assignees';
|
||||
}
|
||||
|
||||
export class UpdateActivityDto {
|
||||
@@ -492,4 +496,8 @@ export class UpdateActivityDto {
|
||||
@IsOptional()
|
||||
@IsIn(['any', 'all'])
|
||||
completionMode?: 'any' | 'all';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['creator', 'assignees'])
|
||||
editPolicy?: 'creator' | 'assignees';
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
startOfLocalDay,
|
||||
} from '@/lib/jalali';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canMutateActivity } from '@/lib/activity-access';
|
||||
import ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
|
||||
@@ -200,6 +201,7 @@ function clearNoteMedia() {
|
||||
const isClosed = computed(
|
||||
() => activity.value?.status === 'done' || activity.value?.status === 'canceled',
|
||||
);
|
||||
const canMutate = computed(() => canMutateActivity(activity.value, auth.user));
|
||||
const needsEveryone = computed(
|
||||
() => activity.value?.completionMode === 'all' && (activity.value.assignees?.length ?? 0) > 1,
|
||||
);
|
||||
@@ -407,6 +409,10 @@ async function runAction() {
|
||||
error.value = '';
|
||||
try {
|
||||
if (isClosed.value) {
|
||||
if (!canMutate.value) {
|
||||
error.value = 'شما مجوز ویرایش این فعالیت را ندارید.';
|
||||
return;
|
||||
}
|
||||
await api.patch(`/activities/${id}`, { status: 'open' });
|
||||
await load(id);
|
||||
emit('changed');
|
||||
@@ -525,7 +531,13 @@ function openFull() {
|
||||
<a-divider :style="{ margin: '16px 0 12px' }" />
|
||||
<a-flex justify="space-between" align="center" :style="{ marginBottom: '8px' }">
|
||||
<a-typography-text strong>یادداشت</a-typography-text>
|
||||
<a-button type="primary" shape="circle" aria-label="یادداشت جدید" @click="commentOpen = true">
|
||||
<a-button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
aria-label="یادداشت جدید"
|
||||
:disabled="!canMutate"
|
||||
@click="commentOpen = true"
|
||||
>
|
||||
<template #icon><PlusOutlined /></template>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
@@ -630,12 +642,16 @@ function openFull() {
|
||||
<a-flex align="center" :gap="8">
|
||||
<a-select
|
||||
v-model:value="postponeKey"
|
||||
:disabled="saving || loading || isClosed"
|
||||
:disabled="saving || loading || isClosed || !canMutate"
|
||||
:options="postponeChoices.map((item) => ({ value: item.key, label: item.label }))"
|
||||
:dropdown-style="{ zIndex: 1200 }"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<a-button type="primary" :disabled="saving || loading || isClosed || !postponeReady" @click="applyPostpone">
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="saving || loading || isClosed || !canMutate || !postponeReady"
|
||||
@click="applyPostpone"
|
||||
>
|
||||
تعویق
|
||||
</a-button>
|
||||
</a-flex>
|
||||
@@ -644,12 +660,17 @@ function openFull() {
|
||||
v-model="customDue"
|
||||
mode="datetime"
|
||||
placeholder="تاریخ و ساعت"
|
||||
:disabled="!canMutate"
|
||||
/>
|
||||
</a-flex>
|
||||
|
||||
<a-divider :style="{ margin: '16px 0 12px' }" />
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
|
||||
<a-button type="primary" :disabled="saving || loading" @click="runAction">
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="saving || loading || (isClosed && !canMutate)"
|
||||
@click="runAction"
|
||||
>
|
||||
{{ actionLabel }}
|
||||
</a-button>
|
||||
<a-button type="link" @click="openFull">مشاهدهٔ کامل</a-button>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ActivityDto, ActivityEditPolicy } from '@mento/shared';
|
||||
|
||||
type ActivityAccessUser = {
|
||||
id?: string | null;
|
||||
role?: string | null;
|
||||
} | null | undefined;
|
||||
|
||||
/** ویرایش/حذف فیلدهای فعالیت — انجام/جابهجایی ستون جداست */
|
||||
export function canMutateActivity(
|
||||
activity: Pick<
|
||||
ActivityDto,
|
||||
'editPolicy' | 'createdBy' | 'assignee' | 'assignees'
|
||||
> | null | undefined,
|
||||
user: ActivityAccessUser,
|
||||
): boolean {
|
||||
if (!activity || !user?.id) return false;
|
||||
if (user.role === 'owner' || user.role === 'admin') return true;
|
||||
if (activity.createdBy?.id === user.id) return true;
|
||||
const policy: ActivityEditPolicy = activity.editPolicy ?? 'creator';
|
||||
if (policy !== 'assignees') return false;
|
||||
if (activity.assignee?.id === user.id) return true;
|
||||
return (activity.assignees ?? []).some((a) => a.id === user.id);
|
||||
}
|
||||
@@ -52,7 +52,10 @@ import type {
|
||||
} from '@mento/shared';
|
||||
import { brand } from '@mento/shared';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canMutateActivity } from '@/lib/activity-access';
|
||||
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
|
||||
import { apiError } from '@/lib/routines';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
|
||||
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
@@ -446,23 +449,11 @@ function deskDetailQuery(columnStageId?: string): Record<string, string> {
|
||||
return query;
|
||||
}
|
||||
|
||||
function activityRoute(id: string, edit = false, columnStageId?: string) {
|
||||
if (!edit) {
|
||||
return {
|
||||
name: 'activity-detail' as const,
|
||||
params: { id },
|
||||
query: deskDetailQuery(columnStageId),
|
||||
};
|
||||
}
|
||||
const query: Record<string, string> = {};
|
||||
for (const key of ['board', 'view', 'desk', 'filter', 'stage', 'q', 'typeDefId', 'status', 'assigneeId', 'title', 'customer', 'due'] as const) {
|
||||
const value = route.query[key];
|
||||
if (typeof value === 'string' && value) query[key] = value;
|
||||
}
|
||||
function activityRoute(id: string, columnStageId?: string) {
|
||||
return {
|
||||
name: 'activity-edit' as const,
|
||||
name: 'activity-detail' as const,
|
||||
params: { id },
|
||||
query,
|
||||
query: deskDetailQuery(columnStageId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -938,10 +929,27 @@ function clearSelect() {
|
||||
selectedIds.value = new Set();
|
||||
}
|
||||
|
||||
function canDeleteActivity(item: ActivityDto | null | undefined) {
|
||||
return canMutateActivity(item, auth.user);
|
||||
}
|
||||
|
||||
function activityById(id: string) {
|
||||
return (
|
||||
items.value.find((row) => row.id === id) ||
|
||||
(boardMenuActivity.value?.id === id ? boardMenuActivity.value : null)
|
||||
);
|
||||
}
|
||||
|
||||
function askDeleteActivity(id: string, title?: string) {
|
||||
menuId.value = null;
|
||||
boardMenuId.value = null;
|
||||
const target = activityById(id);
|
||||
boardMenuActivity.value = null;
|
||||
if (target && !canDeleteActivity(target)) {
|
||||
message.warning('شما مجوز حذف این فعالیت را ندارید.');
|
||||
error.value = 'شما مجوز حذف این فعالیت را ندارید.';
|
||||
return;
|
||||
}
|
||||
deleteSingleTitle.value = title?.trim() || activityTitle(id);
|
||||
deleteActivityIds.value = [id];
|
||||
}
|
||||
@@ -951,8 +959,24 @@ function askBulkDelete() {
|
||||
menuId.value = null;
|
||||
boardMenuId.value = null;
|
||||
boardMenuActivity.value = null;
|
||||
const selected = [...selectedIds.value];
|
||||
const allowed = selected.filter((id) => {
|
||||
const row = activityById(id);
|
||||
return !row || canDeleteActivity(row);
|
||||
});
|
||||
const blocked = selected.length - allowed.length;
|
||||
if (!allowed.length) {
|
||||
message.warning('شما مجوز حذف این فعالیتها را ندارید.');
|
||||
error.value = 'شما مجوز حذف این فعالیتها را ندارید.';
|
||||
return;
|
||||
}
|
||||
if (blocked > 0) {
|
||||
message.warning(
|
||||
`${toFaDigits(blocked)} مورد بهخاطر نبود مجوز حذف کنار گذاشته شد.`,
|
||||
);
|
||||
}
|
||||
deleteSingleTitle.value = '';
|
||||
deleteActivityIds.value = [...selectedIds.value];
|
||||
deleteActivityIds.value = allowed;
|
||||
}
|
||||
|
||||
async function confirmDeleteActivities() {
|
||||
@@ -962,16 +986,24 @@ async function confirmDeleteActivities() {
|
||||
error.value = '';
|
||||
try {
|
||||
const results = await Promise.allSettled(ids.map((id) => api.delete(`/activities/${id}`)));
|
||||
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||
const failed = results.filter((r) => r.status === 'rejected');
|
||||
deleteActivityIds.value = [];
|
||||
clearSelect();
|
||||
kanbanReload.value += 1;
|
||||
await refreshAll();
|
||||
if (failed) {
|
||||
if (failed.length) {
|
||||
const first = failed[0];
|
||||
const detail =
|
||||
first && first.status === 'rejected'
|
||||
? apiError(first.reason, '')
|
||||
: '';
|
||||
error.value =
|
||||
failed === ids.length
|
||||
? 'حذف فعالیتها ناموفق بود.'
|
||||
: `${toFaDigits(failed)} مورد حذف نشد.`;
|
||||
failed.length === ids.length
|
||||
? detail || 'شما مجوز حذف این فعالیت را ندارید.'
|
||||
: detail
|
||||
? `${toFaDigits(failed.length)} مورد حذف نشد: ${detail}`
|
||||
: `${toFaDigits(failed.length)} مورد حذف نشد.`;
|
||||
message.error(error.value);
|
||||
}
|
||||
} finally {
|
||||
activityDeleting.value = false;
|
||||
@@ -1776,16 +1808,18 @@ onUnmounted(() => {
|
||||
<RouterLink :to="activityRoute(item.id)">
|
||||
مشاهده
|
||||
</RouterLink>
|
||||
<RouterLink :to="activityRoute(item.id, true)">
|
||||
ویرایش
|
||||
</RouterLink>
|
||||
<button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)">
|
||||
انتقال به کار جاری
|
||||
</button>
|
||||
<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)">
|
||||
<button
|
||||
v-if="canDeleteActivity(item)"
|
||||
type="button"
|
||||
class="danger"
|
||||
@click="askDeleteActivity(item.id, item.title)"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</UiDropdownMenu>
|
||||
@@ -1866,16 +1900,18 @@ onUnmounted(() => {
|
||||
<RouterLink :to="activityRoute(item.id)">
|
||||
مشاهده
|
||||
</RouterLink>
|
||||
<RouterLink :to="activityRoute(item.id, true)">
|
||||
ویرایش
|
||||
</RouterLink>
|
||||
<button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)">
|
||||
انتقال به کار جاری
|
||||
</button>
|
||||
<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)">
|
||||
<button
|
||||
v-if="canDeleteActivity(item)"
|
||||
type="button"
|
||||
class="danger"
|
||||
@click="askDeleteActivity(item.id, item.title)"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</UiDropdownMenu>
|
||||
@@ -1922,18 +1958,11 @@ onUnmounted(() => {
|
||||
<button type="button" role="menuitem" @click="startSelect(boardMenuId)">انتخاب</button>
|
||||
<RouterLink
|
||||
role="menuitem"
|
||||
:to="activityRoute(boardMenuId, false, viewMode === 'board' ? boardMenuStageId : undefined)"
|
||||
:to="activityRoute(boardMenuId, viewMode === 'board' ? boardMenuStageId : undefined)"
|
||||
@click="boardMenuId = null; boardMenuActivity = null; boardMenuStageId = ''"
|
||||
>
|
||||
مشاهده
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
role="menuitem"
|
||||
:to="activityRoute(boardMenuId, true)"
|
||||
@click="boardMenuId = null; boardMenuActivity = null"
|
||||
>
|
||||
ویرایش
|
||||
</RouterLink>
|
||||
<button
|
||||
v-if="boardMenuActivity && canPromotePrior(boardMenuActivity)"
|
||||
type="button"
|
||||
@@ -1943,6 +1972,7 @@ onUnmounted(() => {
|
||||
انتقال به کار جاری
|
||||
</button>
|
||||
<button
|
||||
v-if="canDeleteActivity(boardMenuActivity)"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="danger"
|
||||
|
||||
@@ -29,7 +29,9 @@ import type {
|
||||
TeamDto,
|
||||
} from '@mento/shared';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canMutateActivity } from '@/lib/activity-access';
|
||||
import { canAssignToOthers } from '@/lib/assign-access';
|
||||
import { apiError } from '@/lib/routines';
|
||||
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
|
||||
import {
|
||||
bindThreadMedia,
|
||||
@@ -76,12 +78,16 @@ async function goBack() {
|
||||
}
|
||||
const auth = useAuthStore();
|
||||
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
||||
const canReassign = computed(() => canAssignToOthers(auth.user, 'activities'));
|
||||
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const activity = ref<ActivityDto | null>(null);
|
||||
const canMutate = computed(() => canMutateActivity(activity.value, auth.user));
|
||||
const editLocked = computed(() => saving.value || !canMutate.value);
|
||||
const canReassign = computed(
|
||||
() => canMutate.value && canAssignToOthers(auth.user, 'activities'),
|
||||
);
|
||||
const boardStages = ref<ActivityStageDto[]>([]);
|
||||
const stageMoving = ref(false);
|
||||
type NoteMedia = {
|
||||
@@ -429,7 +435,9 @@ function clearNoteEdit() {
|
||||
}
|
||||
|
||||
function canEditNote(entry: ActivityThreadEntry): boolean {
|
||||
return Boolean(entry.dateLabel && meName.value && entry.author === meName.value);
|
||||
return Boolean(
|
||||
canMutate.value && entry.dateLabel && meName.value && entry.author === meName.value,
|
||||
);
|
||||
}
|
||||
|
||||
function activityMediaCount(kind: 'image' | 'audio' | 'file') {
|
||||
@@ -557,8 +565,8 @@ async function saveNoteEdit(): Promise<boolean> {
|
||||
clearNoteEdit();
|
||||
await load();
|
||||
return true;
|
||||
} catch {
|
||||
error.value = 'ویرایش یادداشت ناموفق بود.';
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'شما مجوز ویرایش این فعالیت را ندارید.');
|
||||
return false;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
@@ -567,6 +575,7 @@ async function saveNoteEdit(): Promise<boolean> {
|
||||
|
||||
async function persistDrafts(): Promise<boolean> {
|
||||
if (!id.value || !activity.value) return true;
|
||||
if (!canMutate.value) return true;
|
||||
if (editingNoteIndex.value !== null) {
|
||||
const noteOk = await saveNoteEdit();
|
||||
if (!noteOk) return false;
|
||||
@@ -598,8 +607,8 @@ async function persistDrafts(): Promise<boolean> {
|
||||
activity.value = data;
|
||||
hydrateDraft(data);
|
||||
return true;
|
||||
} catch {
|
||||
error.value = 'ثبت تغییرات ناموفق بود.';
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'شما مجوز ویرایش این فعالیت را ندارید.');
|
||||
return false;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
@@ -846,6 +855,10 @@ async function setCompletionMode(mode: 'any' | 'all') {
|
||||
|
||||
async function reopen() {
|
||||
if (!id.value || !activity.value || activity.value.status === 'open') return;
|
||||
if (!canMutate.value) {
|
||||
error.value = 'شما مجوز ویرایش این فعالیت را ندارید.';
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
@@ -887,8 +900,8 @@ async function postponeTo(iso: string) {
|
||||
await api.patch(`/activities/${id.value}`, { dueAt: iso });
|
||||
postponeOpen.value = false;
|
||||
await load();
|
||||
} catch {
|
||||
error.value = 'تعویق فعالیت ناموفق بود.';
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'شما مجوز ویرایش این فعالیت را ندارید.');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
@@ -910,7 +923,7 @@ async function submitComment(payload: { text: string; photoOnly: boolean }) {
|
||||
commentOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = 'ثبت نظر ناموفق بود.';
|
||||
error.value = apiError(e, 'شما مجوز ویرایش این فعالیت را ندارید.');
|
||||
throw e;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
@@ -1056,9 +1069,12 @@ onUnmounted(() => {
|
||||
type="text"
|
||||
maxlength="200"
|
||||
aria-label="عنوان فعالیت"
|
||||
:disabled="saving"
|
||||
:disabled="editLocked"
|
||||
/>
|
||||
</h2>
|
||||
<p v-if="!canMutate" class="error" role="status">
|
||||
شما مجوز ویرایش یا حذف این فعالیت را ندارید.
|
||||
</p>
|
||||
<p v-if="subtitle" class="sub">
|
||||
<UiIcon name="building" :size="14" />
|
||||
{{ subtitle }}
|
||||
@@ -1097,7 +1113,7 @@ onUnmounted(() => {
|
||||
نوع فعالیت
|
||||
</dt>
|
||||
<dd>
|
||||
<select v-model="draftTypeDefId" class="meta-control" :disabled="saving" aria-label="نوع فعالیت">
|
||||
<select v-model="draftTypeDefId" class="meta-control" :disabled="editLocked" aria-label="نوع فعالیت">
|
||||
<option v-for="opt in typeChoices" :key="opt.id" :value="opt.id">{{ opt.name }}</option>
|
||||
</select>
|
||||
</dd>
|
||||
@@ -1108,7 +1124,7 @@ onUnmounted(() => {
|
||||
تاریخ و زمان
|
||||
</dt>
|
||||
<dd>
|
||||
<JalaliDateInput v-model="draftDue" mode="datetime" :disabled="saving" />
|
||||
<JalaliDateInput v-model="draftDue" mode="datetime" :disabled="editLocked" />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
@@ -1117,7 +1133,7 @@ onUnmounted(() => {
|
||||
اولویت
|
||||
</dt>
|
||||
<dd>
|
||||
<select v-model="draftPriority" class="meta-control" :disabled="saving" aria-label="اولویت">
|
||||
<select v-model="draftPriority" class="meta-control" :disabled="editLocked" aria-label="اولویت">
|
||||
<option v-for="(label, key) in priorityLabel" :key="key" :value="key">{{ label }}</option>
|
||||
</select>
|
||||
</dd>
|
||||
@@ -1630,7 +1646,7 @@ onUnmounted(() => {
|
||||
v-if="isDone || activity.status === 'canceled'"
|
||||
class="act primary"
|
||||
type="button"
|
||||
:disabled="saving"
|
||||
:disabled="editLocked"
|
||||
@click="reopen"
|
||||
>
|
||||
<UiIcon name="clock" :size="16" />
|
||||
@@ -1656,11 +1672,21 @@ onUnmounted(() => {
|
||||
<UiIcon name="check" :size="16" />
|
||||
انجام شده
|
||||
</button>
|
||||
<button class="act ghost" type="button" :disabled="saving || isDone" @click="postponeOpen = true">
|
||||
<button
|
||||
class="act ghost"
|
||||
type="button"
|
||||
:disabled="editLocked || isDone"
|
||||
@click="postponeOpen = true"
|
||||
>
|
||||
<UiIcon name="refresh" :size="16" />
|
||||
تعویق
|
||||
</button>
|
||||
<button class="act ghost" type="button" :disabled="saving" @click="commentOpen = true">
|
||||
<button
|
||||
class="act ghost"
|
||||
type="button"
|
||||
:disabled="editLocked"
|
||||
@click="commentOpen = true"
|
||||
>
|
||||
<UiIcon name="chat" :size="16" />
|
||||
ثبت نظر
|
||||
</button>
|
||||
|
||||
@@ -130,6 +130,7 @@ const leadId = ref('');
|
||||
const dealId = ref('');
|
||||
const assigneeIds = ref<string[]>([]);
|
||||
const completionMode = ref<'any' | 'all'>('any');
|
||||
const editPolicy = ref<'creator' | 'assignees'>('creator');
|
||||
const isDone = ref(false);
|
||||
const needsNote = ref(false);
|
||||
const reminderAt = ref('');
|
||||
@@ -442,6 +443,7 @@ function resetForm() {
|
||||
dealId.value = '';
|
||||
assigneeIds.value = auth.user?.id ? [auth.user.id] : [];
|
||||
completionMode.value = 'any';
|
||||
editPolicy.value = 'creator';
|
||||
pickedRoleIds.value = [];
|
||||
pickedTeamIds.value = [];
|
||||
isDone.value = false;
|
||||
@@ -531,6 +533,7 @@ async function load() {
|
||||
? [auth.user.id]
|
||||
: [];
|
||||
completionMode.value = data.completionMode === 'all' ? 'all' : 'any';
|
||||
editPolicy.value = data.editPolicy === 'assignees' ? 'assignees' : 'creator';
|
||||
refreshPickedGroups();
|
||||
isDone.value = data.status === 'done';
|
||||
await loadSavedPhotos(data);
|
||||
@@ -619,6 +622,7 @@ function payload(): CreateActivityRequest {
|
||||
assigneeIds: [...assigneeIds.value],
|
||||
assigneeId: assigneeIds.value[0] || null,
|
||||
completionMode: assigneeIds.value.length > 1 ? completionMode.value : 'any',
|
||||
editPolicy: editPolicy.value,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1332,6 +1336,29 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
<label>ویرایش و حذف</label>
|
||||
<div class="completion-mode">
|
||||
<label>
|
||||
<input v-model="editPolicy" type="radio" value="creator" />
|
||||
فقط خودم (ایجادکننده)
|
||||
</label>
|
||||
<label>
|
||||
<input v-model="editPolicy" type="radio" value="assignees" />
|
||||
همهٔ مسئولان
|
||||
</label>
|
||||
</div>
|
||||
<small class="hint">
|
||||
{{
|
||||
editPolicy === 'assignees'
|
||||
? 'مسئولان هم میتوانند این فعالیت را ویرایش یا حذف کنند.'
|
||||
: 'فقط ایجادکننده میتواند این فعالیت را ویرایش یا حذف کند.'
|
||||
}}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
<div class="label-row">
|
||||
|
||||
@@ -2124,6 +2124,14 @@ export interface ActivityImageDto {
|
||||
|
||||
export type ActivityCompletionMode = "any" | "all";
|
||||
|
||||
/** چه کسانی میتوانند فعالیت را ویرایش یا حذف کنند (علاوه بر owner/admin) */
|
||||
export type ActivityEditPolicy = "creator" | "assignees";
|
||||
|
||||
export const ACTIVITY_EDIT_POLICY_LABELS: Record<ActivityEditPolicy, string> = {
|
||||
creator: "فقط ایجادکننده",
|
||||
assignees: "همهٔ مسئولان",
|
||||
};
|
||||
|
||||
export interface ActivityDto {
|
||||
id: string;
|
||||
type: ActivityType;
|
||||
@@ -2145,6 +2153,8 @@ export interface ActivityDto {
|
||||
completionMode?: ActivityCompletionMode;
|
||||
/** شناسهٔ مسئولانی که سهم خود را در حالت all ثبت کردهاند */
|
||||
completedByIds?: string[];
|
||||
/** پیشفرض creator؛ assignees یعنی همهٔ مسئولان هم میتوانند ویرایش/حذف کنند */
|
||||
editPolicy?: ActivityEditPolicy;
|
||||
/** manual یا didar؛ برای جدا کردن سابقهٔ قبلی */
|
||||
importSource?: string;
|
||||
promotedFromPriorAt?: string | null;
|
||||
@@ -2204,6 +2214,8 @@ export interface CreateActivityRequest {
|
||||
assigneeIds?: string[];
|
||||
/** پیشفرض any. فقط وقتی چند مسئول دارد معنی دارد */
|
||||
completionMode?: ActivityCompletionMode;
|
||||
/** پیشفرض creator */
|
||||
editPolicy?: ActivityEditPolicy;
|
||||
}
|
||||
|
||||
export interface UpdateActivityRequest {
|
||||
@@ -2229,6 +2241,7 @@ export interface UpdateActivityRequest {
|
||||
assigneeId?: string | null;
|
||||
assigneeIds?: string[];
|
||||
completionMode?: ActivityCompletionMode;
|
||||
editPolicy?: ActivityEditPolicy;
|
||||
}
|
||||
|
||||
export interface ActivitySummaryDto {
|
||||
|
||||
Reference in New Issue
Block a user