feat(activities): keep multi-assignee completion readable for large teams

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-09 21:30:05 +03:30
co-authored by Cursor
parent da851bd9e5
commit 7a8007fff0
3 changed files with 374 additions and 24 deletions
+71 -16
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Select as ASelect } from 'ant-design-vue';
import { Button as AButton, Select as ASelect } from 'ant-design-vue';
import { toFaDigits } from '@/lib/jalali';
type PickUser = { id: string; fullName: string };
@@ -13,8 +14,10 @@ const props = withDefaults(
min?: number;
addLabel?: string;
emptyLabel?: string;
/** دکمهٔ انتخاب یکجای همهٔ اعضا و جمع‌کردن برچسب‌ها وقتی تعداد زیاد است */
selectAll?: boolean;
}>(),
{ disabled: false, min: 0, addLabel: 'افزودن', emptyLabel: 'کسی انتخاب نشده' },
{ disabled: false, min: 0, addLabel: 'افزودن', emptyLabel: 'کسی انتخاب نشده', selectAll: false },
);
const emit = defineEmits<{
@@ -26,8 +29,25 @@ const options = computed(() =>
props.users.map((u) => ({ value: u.id, label: u.fullName })),
);
function onUpdate(value: unknown) {
const ids = Array.isArray(value) ? value.map(String) : [];
const allPicked = computed(
() =>
props.users.length > 0 &&
props.users.every((user) => props.modelValue.includes(user.id)),
);
const tagLimit = computed(() => {
if (!props.selectAll) return undefined;
if (allPicked.value && props.users.length > 2) return 0;
return 'responsive' as const;
});
function tagPlaceholder(omitted: unknown[]) {
if (allPicked.value) return `همهٔ اعضا (${toFaDigits(props.users.length)})`;
const extra = Array.isArray(omitted) ? omitted.length : 0;
return extra ? `${toFaDigits(extra)} نفر دیگر` : '';
}
function commit(ids: string[]) {
if (ids.length < props.min && props.modelValue.length >= props.min) {
emit('update:modelValue', [...props.modelValue]);
return;
@@ -35,19 +55,54 @@ function onUpdate(value: unknown) {
emit('update:modelValue', ids);
emit('change', ids);
}
function onUpdate(value: unknown) {
const ids = Array.isArray(value) ? value.map(String) : [];
commit(ids);
}
function pickAll() {
if (props.disabled || allPicked.value) return;
commit(props.users.map((user) => user.id));
}
</script>
<template>
<a-select
mode="multiple"
allow-clear
show-search
option-filter-prop="label"
:value="modelValue"
:options="options"
:disabled="disabled"
:placeholder="emptyLabel"
style="width: 100%"
@update:value="onUpdate"
/>
<div class="user-multi" :class="{ 'with-all': selectAll }">
<a-select
mode="multiple"
allow-clear
show-search
option-filter-prop="label"
:value="modelValue"
:options="options"
:disabled="disabled"
:placeholder="emptyLabel"
:max-tag-count="tagLimit"
:max-tag-placeholder="selectAll ? tagPlaceholder : undefined"
style="width: 100%"
@update:value="onUpdate"
/>
<a-button v-if="selectAll" :disabled="disabled || allPicked || !users.length" @click="pickAll">
همه
</a-button>
</div>
</template>
<style scoped>
.user-multi.with-all {
display: flex;
align-items: flex-start;
gap: 8px;
width: 100%;
}
.user-multi.with-all :deep(.ant-select) {
flex: 1 1 auto;
min-width: 0;
}
.user-multi.with-all :deep(.ant-btn) {
flex: 0 0 auto;
}
</style>
+257 -4
View File
@@ -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<OrgUserDto[]>([]);
const teams = ref<TeamDto[]>([]);
const assignUserIds = ref<string[]>([]);
const shareOpen = ref<Record<string, boolean>>({});
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<ShareGroup[]>(() => {
const people = assigneePeople.value;
if (people.length < 2) return [];
const byId = new Map(people.map((person) => [person.id, person]));
const claimed = new Set<string>();
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<string, { name: string; members: SharePerson[] }>();
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<ActivityDto>(`/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<OrgUserDto[]>('/users');
users.value = data;
const [usersRes, teamsRes] = await Promise.all([
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
api.get<TeamDto[]>('/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"
/>
<template v-else>
@@ -660,7 +772,39 @@ onMounted(() => {
همه باید انجام دهند
</button>
</div>
<ul v-if="needsEveryone && !isDone" class="share-list">
<div v-if="needsEveryone && !isDone && shareUsesGroups" class="share-board">
<p class="share-progress">
{{ toFaDigits(shareDoneTotal) }} از {{ toFaDigits(assigneePeople.length) }} انجام شده
</p>
<div v-for="group in shareGroups" :key="group.key" class="share-group">
<button type="button" class="share-group-head" @click="toggleShare(group)">
<UiIcon
name="chevron"
:size="14"
class="share-caret"
:class="{ open: shareExpanded(group) }"
/>
<span class="share-group-name">
<small v-if="shareKindLabel(group)">{{ shareKindLabel(group) }}</small>
{{ group.name }}
</span>
<em :class="{ done: shareDoneCount(group.members) === group.members.length }">
{{ toFaDigits(shareDoneCount(group.members)) }} از {{ toFaDigits(group.members.length) }}
</em>
</button>
<ul v-if="shareExpanded(group)" class="share-members">
<li
v-for="person in group.members"
:key="person.id"
:class="{ done: completedIds.has(person.id) }"
>
<span>{{ person.fullName }}</span>
<em>{{ completedIds.has(person.id) ? 'انجام شد' : 'مانده' }}</em>
</li>
</ul>
</div>
</div>
<ul v-else-if="needsEveryone && !isDone" class="share-list">
<li
v-for="person in assigneePeople"
:key="person.id"
@@ -1304,6 +1448,115 @@ dd {
color: #0f766e;
}
.share-board {
width: 100%;
margin-top: 0.55rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.share-progress {
margin: 0;
color: #64748b;
font-size: 0.75rem;
font-weight: 600;
}
.share-group {
border: 1px solid #e2e8f0;
border-radius: 0.7rem;
background: #fff;
overflow: hidden;
}
.share-group-head {
width: 100%;
display: flex;
align-items: center;
gap: 0.4rem;
border: 0;
background: transparent;
padding: 0.4rem 0.65rem;
font: inherit;
font-size: 0.84rem;
font-weight: 600;
color: #334155;
text-align: start;
cursor: pointer;
}
.share-group-name {
flex: 1;
min-width: 0;
}
.share-group-name small {
margin-inline-end: 0.3rem;
color: #94a3b8;
font-size: 0.72rem;
font-weight: 600;
}
.share-group-head em {
font-style: normal;
color: #64748b;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.share-group-head em.done {
color: #0f766e;
}
.share-group-head :deep(.share-caret) {
color: #94a3b8;
transform: rotate(90deg);
}
.share-group-head :deep(.share-caret.open) {
transform: rotate(-90deg);
}
.share-members {
list-style: none;
margin: 0;
padding: 0.15rem 0.65rem 0.5rem;
max-height: 14rem;
overflow: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(13.5rem, 1fr));
gap: 0.2rem 0.75rem;
}
.share-members li {
display: flex;
justify-content: space-between;
gap: 0.5rem;
min-width: 0;
font-size: 0.8rem;
font-weight: 500;
color: #334155;
}
.share-members li span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.share-members em {
flex: 0 0 auto;
font-style: normal;
color: #94a3b8;
}
.share-members li.done em {
color: #0f766e;
}
.share-force {
margin-top: 0.45rem;
border: 0;
+46 -4
View File
@@ -1285,6 +1285,9 @@ watch([id, isNew], () => void load(), { immediate: true });
<input v-model="completionMode" type="radio" value="all" />
همهٔ مسئول‌ها باید انجام دهند
</label>
<p v-if="completionMode === 'all'" class="completion-hint">
هر کدام از همین افراد باید جداگانه سهم خودش را انجام دهد.
</p>
</div>
<small class="hint">
{{
@@ -1819,14 +1822,16 @@ watch([id, isNew], () => void load(), { immediate: true });
.completion-mode {
display: flex;
flex-wrap: wrap;
gap: 0.65rem 1rem;
align-items: center;
gap: 0.4rem;
margin-top: 0.75rem;
color: #334155;
font-size: 0.88rem;
}
.completion-mode span {
.completion-mode > span {
width: 100%;
margin: 0;
color: #64748b;
font-size: 0.75rem;
font-weight: 600;
}
@@ -1834,9 +1839,46 @@ watch([id, isNew], () => void load(), { immediate: true });
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin: 0;
padding: 0.2rem 0.65rem;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #fff;
color: #334155;
font-size: 0.8rem;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
cursor: pointer;
}
.completion-mode label:has(input:checked) {
border-color: #14b8a6;
background: #f0fdfa;
color: #0f766e;
}
.completion-mode input {
width: 14px;
height: 14px;
min-height: 0;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
flex: 0 0 14px;
accent-color: #0d9488;
background: transparent;
}
.completion-hint {
width: 100%;
margin: 0.15rem 0 0;
color: #64748b;
font-size: 0.75rem;
line-height: 1.5;
}
.assignee-chips {
display: flex;
flex-wrap: wrap;