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
+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;