From 7a8007fff0f3d6c7f0ecbd139710bf1988cebfe9 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Mon, 9 Nov 2026 21:30:05 +0330 Subject: [PATCH] feat(activities): keep multi-assignee completion readable for large teams Co-authored-by: Cursor --- apps/web/src/components/UserMultiSelect.vue | 87 +++++-- apps/web/src/views/ActivityDetailView.vue | 261 +++++++++++++++++++- apps/web/src/views/ActivityFormView.vue | 50 +++- 3 files changed, 374 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/UserMultiSelect.vue b/apps/web/src/components/UserMultiSelect.vue index aaff795..982e961 100644 --- a/apps/web/src/components/UserMultiSelect.vue +++ b/apps/web/src/components/UserMultiSelect.vue @@ -1,6 +1,7 @@ + + diff --git a/apps/web/src/views/ActivityDetailView.vue b/apps/web/src/views/ActivityDetailView.vue index 898f0c7..e48a2b0 100644 --- a/apps/web/src/views/ActivityDetailView.vue +++ b/apps/web/src/views/ActivityDetailView.vue @@ -20,6 +20,7 @@ import type { ActivityDto, ActivityPriority, OrgUserDto, + TeamDto, } from '@mento/shared'; import { useAuthStore } from '@/stores/auth'; import { canAssignToOthers } from '@/lib/assign-access'; @@ -171,7 +172,9 @@ watch( { immediate: true }, ); const users = ref([]); +const teams = ref([]); const assignUserIds = ref([]); +const shareOpen = ref>({}); const assigneePeople = computed(() => { const a = activity.value; @@ -187,7 +190,110 @@ const needsEveryone = computed( () => activity.value?.completionMode === 'all' && assigneePeople.value.length > 1, ); + +type SharePerson = { id: string; fullName: string }; +type ShareGroup = { + key: string; + kind: 'team' | 'role' | 'rest'; + name: string; + members: SharePerson[]; +}; + +const shareGroups = computed(() => { + const people = assigneePeople.value; + if (people.length < 2) return []; + const byId = new Map(people.map((person) => [person.id, person])); + const claimed = new Set(); + const groups: ShareGroup[] = []; + + const teamCandidates = teams.value + .map((team) => { + const members = [ + ...new Map( + team.members + .map((member) => byId.get(member.userId)) + .filter((person): person is SharePerson => Boolean(person)) + .map((person) => [person.id, person]), + ).values(), + ].sort((a, b) => a.fullName.localeCompare(b.fullName, 'fa')); + return { id: team.id, name: team.name, members }; + }) + .filter((team) => team.members.length >= 2) + .sort( + (a, b) => b.members.length - a.members.length || a.name.localeCompare(b.name, 'fa'), + ); + + for (const team of teamCandidates) { + const free = team.members.filter((member) => !claimed.has(member.id)); + if (free.length < 2) continue; + for (const member of free) claimed.add(member.id); + groups.push({ key: `team:${team.id}`, kind: 'team', name: team.name, members: free }); + } + + const roleMap = new Map(); + for (const user of users.value) { + const person = byId.get(user.id); + if (!person || claimed.has(person.id) || !user.orgRoleId || !user.orgRoleName) continue; + const row = roleMap.get(user.orgRoleId) ?? { name: user.orgRoleName, members: [] }; + row.members.push(person); + roleMap.set(user.orgRoleId, row); + } + const roles = [...roleMap.entries()] + .map(([id, role]) => ({ + id, + name: role.name, + members: [...role.members].sort((a, b) => a.fullName.localeCompare(b.fullName, 'fa')), + })) + .filter((role) => role.members.length >= 2) + .sort((a, b) => b.members.length - a.members.length || a.name.localeCompare(b.name, 'fa')); + for (const role of roles) { + for (const member of role.members) claimed.add(member.id); + groups.push({ key: `role:${role.id}`, kind: 'role', name: role.name, members: role.members }); + } + + const rest = people + .filter((person) => !claimed.has(person.id)) + .sort((a, b) => a.fullName.localeCompare(b.fullName, 'fa')); + if (rest.length) { + groups.push({ + key: 'rest', + kind: 'rest', + name: groups.length ? 'سایر مسئول‌ها' : 'مسئول‌ها', + members: rest, + }); + } + return groups; +}); + const completedIds = computed(() => new Set(activity.value?.completedByIds ?? [])); + +const shareUsesGroups = computed( + () => + shareGroups.value.some((group) => group.kind !== 'rest' || group.members.length > 6), +); + +const shareDoneTotal = computed( + () => assigneePeople.value.filter((person) => completedIds.value.has(person.id)).length, +); + +function shareDoneCount(members: SharePerson[]) { + return members.filter((person) => completedIds.value.has(person.id)).length; +} + +function shareKindLabel(group: ShareGroup) { + if (group.kind === 'team' && !group.name.startsWith('تیم')) return 'تیم'; + if (group.kind === 'role' && !group.name.startsWith('نقش')) return 'نقش'; + return ''; +} + +function shareExpanded(group: ShareGroup) { + if (group.key in shareOpen.value) return shareOpen.value[group.key]; + return group.kind === 'rest' && group.members.length <= 6; +} + +function toggleShare(group: ShareGroup) { + shareOpen.value = { ...shareOpen.value, [group.key]: !shareExpanded(group) }; +} const myShareDone = computed(() => { const mine = auth.user?.id; return Boolean(mine && completedIds.value.has(mine)); @@ -278,6 +384,7 @@ async function load() { try { const { data } = await api.get(`/activities/${id.value}`); activity.value = data; + shareOpen.value = {}; await loadActivityImages(data); assignUserIds.value = data.assignees?.length ? data.assignees.map((u) => u.id) @@ -324,12 +431,16 @@ async function loadActivityImages(row: ActivityDto) { onUnmounted(clearImageUrls); async function loadUsers() { - if (!canReassign.value) return; try { - const { data } = await api.get('/users'); - users.value = data; + const [usersRes, teamsRes] = await Promise.all([ + api.get('/users').catch(() => ({ data: [] as OrgUserDto[] })), + api.get('/teams').catch(() => ({ data: [] as TeamDto[] })), + ]); + users.value = usersRes.data; + teams.value = teamsRes.data; } catch { users.value = []; + teams.value = []; } } @@ -637,6 +748,7 @@ onMounted(() => { :users="users" :disabled="saving" :min="1" + select-all @change="reassign" />