feat(activities): keep multi-assignee completion readable for large teams
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,9 +55,20 @@ 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>
|
||||
<div class="user-multi" :class="{ 'with-all': selectAll }">
|
||||
<a-select
|
||||
mode="multiple"
|
||||
allow-clear
|
||||
@@ -47,7 +78,31 @@ function onUpdate(value: unknown) {
|
||||
: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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user