feat(activities): pick assignees by team or role
A team or role adds every member in one step, while each person's completion share stays unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,14 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { Button as AButton, Select as ASelect } from 'ant-design-vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
|
||||
type PickUser = { id: string; fullName: string };
|
||||
type PickUser = { id: string; fullName: string; orgRoleName?: string | null };
|
||||
type AssigneeGroup = { id: string; name: string; userIds: string[] };
|
||||
type GroupKind = 'role' | 'team';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string[];
|
||||
users: PickUser[];
|
||||
/** انتخاب یک نقش، همهٔ اعضایش را مسئول میکند */
|
||||
roles?: AssigneeGroup[];
|
||||
/** انتخاب یک تیم، همهٔ اعضایش را مسئول میکند */
|
||||
teams?: AssigneeGroup[];
|
||||
disabled?: boolean;
|
||||
/** حداقل تعداد انتخاب (حذف آخرین نفر غیرفعال میشود) */
|
||||
min?: number;
|
||||
@@ -17,7 +23,15 @@ const props = withDefaults(
|
||||
/** دکمهٔ انتخاب یکجای همهٔ اعضا و جمعکردن برچسبها وقتی تعداد زیاد است */
|
||||
selectAll?: boolean;
|
||||
}>(),
|
||||
{ disabled: false, min: 0, addLabel: 'افزودن', emptyLabel: 'کسی انتخاب نشده', selectAll: false },
|
||||
{
|
||||
roles: () => [],
|
||||
teams: () => [],
|
||||
disabled: false,
|
||||
min: 0,
|
||||
addLabel: 'افزودن',
|
||||
emptyLabel: 'کسی انتخاب نشده',
|
||||
selectAll: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -25,10 +39,88 @@ const emit = defineEmits<{
|
||||
change: [ids: string[]];
|
||||
}>();
|
||||
|
||||
const options = computed(() =>
|
||||
props.users.map((u) => ({ value: u.id, label: u.fullName })),
|
||||
const pinned = ref(new Set<string>());
|
||||
|
||||
function uniqueIds(ids: string[]) {
|
||||
return [...new Set(ids.filter(Boolean))];
|
||||
}
|
||||
|
||||
function normalizeSearch(value: string) {
|
||||
return value.replace(/ي/g, 'ی').replace(/ك/g, 'ک').replace(/\u200c/g, ' ').trim().toLowerCase();
|
||||
}
|
||||
|
||||
const catalog = computed(() => {
|
||||
const rows: Array<{ token: string; name: string; userIds: string[]; kind: GroupKind }> = [];
|
||||
const push = (kind: GroupKind, groups: AssigneeGroup[]) => {
|
||||
for (const group of groups) {
|
||||
const userIds = uniqueIds(group.userIds);
|
||||
if (!userIds.length || !group.id || !group.name) continue;
|
||||
rows.push({
|
||||
token: `${kind}:${group.id}`,
|
||||
name: group.name,
|
||||
userIds,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
};
|
||||
push('role', props.roles);
|
||||
push('team', props.teams);
|
||||
return rows;
|
||||
});
|
||||
|
||||
const catalogByToken = computed(() => new Map(catalog.value.map((group) => [group.token, group])));
|
||||
|
||||
function covers(userIds: string[], selected: string[]) {
|
||||
return userIds.length > 0 && userIds.every((id) => selected.includes(id));
|
||||
}
|
||||
|
||||
const shownGroups = computed(() =>
|
||||
catalog.value.filter((group) => {
|
||||
if (!covers(group.userIds, props.modelValue)) return false;
|
||||
if (group.userIds.length >= 2) return true;
|
||||
return pinned.value.has(group.token);
|
||||
}),
|
||||
);
|
||||
|
||||
const selectValue = computed(() => {
|
||||
const covered = new Set(shownGroups.value.flatMap((group) => group.userIds));
|
||||
return [
|
||||
...shownGroups.value.map((group) => group.token),
|
||||
...props.modelValue.filter((id) => !covered.has(id)),
|
||||
];
|
||||
});
|
||||
|
||||
const options = computed(() => {
|
||||
const people = props.users.map((user) => ({
|
||||
value: user.id,
|
||||
label:
|
||||
catalog.value.length && user.orgRoleName
|
||||
? `${user.fullName} — ${user.orgRoleName}`
|
||||
: user.fullName,
|
||||
}));
|
||||
if (!catalog.value.length) return people;
|
||||
const grouped: Array<{ label: string; options: Array<{ value: string; label: string }> }> = [];
|
||||
const section = (kind: GroupKind, title: string) => {
|
||||
const items = catalog.value
|
||||
.filter((group) => group.kind === kind)
|
||||
.map((group) => ({
|
||||
value: group.token,
|
||||
label: `${group.name} (${toFaDigits(group.userIds.length)} نفر)`,
|
||||
}));
|
||||
if (items.length) grouped.push({ label: title, options: items });
|
||||
};
|
||||
section('role', 'نقشها');
|
||||
section('team', 'تیمها');
|
||||
if (people.length) grouped.push({ label: 'اعضا', options: people });
|
||||
return grouped;
|
||||
});
|
||||
|
||||
function filterOption(input: string, option?: { label?: unknown }) {
|
||||
const label = String(option?.label ?? '');
|
||||
if (!label || !input.trim()) return true;
|
||||
return normalizeSearch(label).includes(normalizeSearch(input));
|
||||
}
|
||||
|
||||
const allPicked = computed(
|
||||
() =>
|
||||
props.users.length > 0 &&
|
||||
@@ -36,15 +128,33 @@ const allPicked = computed(
|
||||
);
|
||||
|
||||
const tagLimit = computed(() => {
|
||||
if (!props.selectAll) return undefined;
|
||||
if (allPicked.value && props.users.length > 2) return 0;
|
||||
return 'responsive' as const;
|
||||
if (props.selectAll && allPicked.value && props.users.length > 2) return 0;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
function tagPlaceholder(omitted: unknown[]) {
|
||||
if (allPicked.value) return `همهٔ اعضا (${toFaDigits(props.users.length)})`;
|
||||
const extra = Array.isArray(omitted) ? omitted.length : 0;
|
||||
return extra ? `${toFaDigits(extra)} نفر دیگر` : '';
|
||||
if (!extra) return '';
|
||||
return catalog.value.length ? `${toFaDigits(extra)} مورد دیگر` : `${toFaDigits(extra)} نفر دیگر`;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue.join('\0'),
|
||||
() => {
|
||||
const next = new Set<string>();
|
||||
for (const token of pinned.value) {
|
||||
const group = catalogByToken.value.get(token);
|
||||
if (group && covers(group.userIds, props.modelValue)) next.add(token);
|
||||
}
|
||||
pinned.value = next;
|
||||
},
|
||||
);
|
||||
|
||||
function sameIds(left: string[], right: string[]) {
|
||||
if (left.length !== right.length) return false;
|
||||
const have = new Set(left);
|
||||
return right.every((id) => have.has(id));
|
||||
}
|
||||
|
||||
function commit(ids: string[]) {
|
||||
@@ -53,12 +163,29 @@ function commit(ids: string[]) {
|
||||
return;
|
||||
}
|
||||
emit('update:modelValue', ids);
|
||||
emit('change', ids);
|
||||
if (!sameIds(ids, props.modelValue)) emit('change', ids);
|
||||
}
|
||||
|
||||
function onUpdate(value: unknown) {
|
||||
const ids = Array.isArray(value) ? value.map(String) : [];
|
||||
commit(ids);
|
||||
const tokens = Array.isArray(value) ? value.map(String) : [];
|
||||
const ids = new Set<string>();
|
||||
const pins = new Set<string>();
|
||||
for (const token of tokens) {
|
||||
const group = catalogByToken.value.get(token);
|
||||
if (group) {
|
||||
pins.add(token);
|
||||
for (const id of group.userIds) ids.add(id);
|
||||
continue;
|
||||
}
|
||||
if (!token.startsWith('role:') && !token.startsWith('team:')) ids.add(token);
|
||||
}
|
||||
const next = [...ids];
|
||||
if (next.length < props.min && props.modelValue.length >= props.min) {
|
||||
emit('update:modelValue', [...props.modelValue]);
|
||||
return;
|
||||
}
|
||||
pinned.value = pins;
|
||||
commit(next);
|
||||
}
|
||||
|
||||
function pickAll() {
|
||||
@@ -73,13 +200,13 @@ function pickAll() {
|
||||
mode="multiple"
|
||||
allow-clear
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
:value="modelValue"
|
||||
:value="selectValue"
|
||||
:options="options"
|
||||
:disabled="disabled"
|
||||
:placeholder="emptyLabel"
|
||||
:filter-option="filterOption"
|
||||
:max-tag-count="tagLimit"
|
||||
:max-tag-placeholder="selectAll ? tagPlaceholder : undefined"
|
||||
:max-tag-placeholder="selectAll && allPicked ? tagPlaceholder : undefined"
|
||||
style="width: 100%"
|
||||
@update:value="onUpdate"
|
||||
/>
|
||||
|
||||
@@ -182,6 +182,35 @@ const assigneePeople = computed(() => {
|
||||
if (a.assignees?.length) return a.assignees;
|
||||
return a.assignee ? [a.assignee] : [];
|
||||
});
|
||||
const assigneeRoleChoices = computed(() => {
|
||||
const map = new Map<string, { id: string; name: string; userIds: string[] }>();
|
||||
for (const user of users.value) {
|
||||
if (user.isActive === false || !user.orgRoleId || !user.orgRoleName) continue;
|
||||
const row = map.get(user.orgRoleId) ?? {
|
||||
id: user.orgRoleId,
|
||||
name: user.orgRoleName,
|
||||
userIds: [],
|
||||
};
|
||||
row.userIds.push(user.id);
|
||||
map.set(user.orgRoleId, row);
|
||||
}
|
||||
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name, 'fa'));
|
||||
});
|
||||
|
||||
const assigneeTeamChoices = computed(() => {
|
||||
const active = new Set(
|
||||
users.value.filter((user) => user.isActive !== false).map((user) => user.id),
|
||||
);
|
||||
return teams.value
|
||||
.map((team) => ({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
userIds: [...new Set(team.members.map((member) => member.userId).filter((id) => active.has(id)))],
|
||||
}))
|
||||
.filter((team) => team.userIds.length > 0)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'fa'));
|
||||
});
|
||||
|
||||
const assigneeNames = computed(() => {
|
||||
const list = assigneePeople.value.map((u) => u.fullName);
|
||||
return list.length ? list.join('، ') : '—';
|
||||
@@ -746,6 +775,8 @@ onMounted(() => {
|
||||
v-if="canReassign"
|
||||
v-model="assignUserIds"
|
||||
:users="users"
|
||||
:roles="assigneeRoleChoices"
|
||||
:teams="assigneeTeamChoices"
|
||||
:disabled="saving"
|
||||
:min="1"
|
||||
select-all
|
||||
|
||||
Reference in New Issue
Block a user