2026-10-05 17:49:17 +03:30
|
|
|
<script setup lang="ts">
|
2026-11-10 17:39:51 +03:30
|
|
|
import { computed, ref, watch } from 'vue';
|
2026-11-09 21:30:05 +03:30
|
|
|
import { Button as AButton, Select as ASelect } from 'ant-design-vue';
|
|
|
|
|
import { toFaDigits } from '@/lib/jalali';
|
2026-10-05 17:49:17 +03:30
|
|
|
|
2026-11-10 17:39:51 +03:30
|
|
|
type PickUser = { id: string; fullName: string; orgRoleName?: string | null };
|
|
|
|
|
type AssigneeGroup = { id: string; name: string; userIds: string[] };
|
|
|
|
|
type GroupKind = 'role' | 'team';
|
2026-10-05 17:49:17 +03:30
|
|
|
|
|
|
|
|
const props = withDefaults(
|
|
|
|
|
defineProps<{
|
|
|
|
|
modelValue: string[];
|
|
|
|
|
users: PickUser[];
|
2026-11-10 17:39:51 +03:30
|
|
|
/** انتخاب یک نقش، همهٔ اعضایش را مسئول میکند */
|
|
|
|
|
roles?: AssigneeGroup[];
|
|
|
|
|
/** انتخاب یک تیم، همهٔ اعضایش را مسئول میکند */
|
|
|
|
|
teams?: AssigneeGroup[];
|
2026-10-05 17:49:17 +03:30
|
|
|
disabled?: boolean;
|
|
|
|
|
/** حداقل تعداد انتخاب (حذف آخرین نفر غیرفعال میشود) */
|
|
|
|
|
min?: number;
|
|
|
|
|
addLabel?: string;
|
|
|
|
|
emptyLabel?: string;
|
2026-11-09 21:30:05 +03:30
|
|
|
/** دکمهٔ انتخاب یکجای همهٔ اعضا و جمعکردن برچسبها وقتی تعداد زیاد است */
|
|
|
|
|
selectAll?: boolean;
|
2026-10-05 17:49:17 +03:30
|
|
|
}>(),
|
2026-11-10 17:39:51 +03:30
|
|
|
{
|
|
|
|
|
roles: () => [],
|
|
|
|
|
teams: () => [],
|
|
|
|
|
disabled: false,
|
|
|
|
|
min: 0,
|
|
|
|
|
addLabel: 'افزودن',
|
|
|
|
|
emptyLabel: 'کسی انتخاب نشده',
|
|
|
|
|
selectAll: false,
|
|
|
|
|
},
|
2026-10-05 17:49:17 +03:30
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
'update:modelValue': [ids: string[]];
|
|
|
|
|
change: [ids: string[]];
|
|
|
|
|
}>();
|
|
|
|
|
|
2026-11-10 17:39:51 +03:30
|
|
|
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);
|
|
|
|
|
}),
|
2026-10-05 17:49:17 +03:30
|
|
|
);
|
|
|
|
|
|
2026-11-10 17:39:51 +03:30
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-09 21:30:05 +03:30
|
|
|
const allPicked = computed(
|
|
|
|
|
() =>
|
|
|
|
|
props.users.length > 0 &&
|
|
|
|
|
props.users.every((user) => props.modelValue.includes(user.id)),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const tagLimit = computed(() => {
|
2026-11-10 17:39:51 +03:30
|
|
|
if (props.selectAll && allPicked.value && props.users.length > 2) return 0;
|
|
|
|
|
return undefined;
|
2026-11-09 21:30:05 +03:30
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function tagPlaceholder(omitted: unknown[]) {
|
|
|
|
|
if (allPicked.value) return `همهٔ اعضا (${toFaDigits(props.users.length)})`;
|
|
|
|
|
const extra = Array.isArray(omitted) ? omitted.length : 0;
|
2026-11-10 17:39:51 +03:30
|
|
|
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));
|
2026-11-09 21:30:05 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function commit(ids: string[]) {
|
2026-10-18 19:39:59 +03:30
|
|
|
if (ids.length < props.min && props.modelValue.length >= props.min) {
|
|
|
|
|
emit('update:modelValue', [...props.modelValue]);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
emit('update:modelValue', ids);
|
2026-11-10 17:39:51 +03:30
|
|
|
if (!sameIds(ids, props.modelValue)) emit('change', ids);
|
2026-10-05 17:49:17 +03:30
|
|
|
}
|
2026-11-09 21:30:05 +03:30
|
|
|
|
|
|
|
|
function onUpdate(value: unknown) {
|
2026-11-10 17:39:51 +03:30
|
|
|
const tokens = Array.isArray(value) ? value.map(String) : [];
|
2026-11-10 18:43:02 +03:30
|
|
|
const tokenSet = new Set(tokens);
|
|
|
|
|
const removedMemberIds = new Set<string>();
|
|
|
|
|
for (const token of selectValue.value) {
|
|
|
|
|
if (tokenSet.has(token)) continue;
|
|
|
|
|
const dropped = catalogByToken.value.get(token);
|
|
|
|
|
if (!dropped) continue;
|
|
|
|
|
for (const id of dropped.userIds) removedMemberIds.add(id);
|
|
|
|
|
}
|
2026-11-10 17:39:51 +03:30
|
|
|
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);
|
2026-11-10 18:43:02 +03:30
|
|
|
for (const id of group.userIds) {
|
|
|
|
|
if (removedMemberIds.has(id)) continue;
|
|
|
|
|
ids.add(id);
|
|
|
|
|
}
|
2026-11-10 17:39:51 +03:30
|
|
|
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);
|
2026-11-09 21:30:05 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pickAll() {
|
|
|
|
|
if (props.disabled || allPicked.value) return;
|
|
|
|
|
commit(props.users.map((user) => user.id));
|
|
|
|
|
}
|
2026-10-05 17:49:17 +03:30
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-11-09 21:30:05 +03:30
|
|
|
<div class="user-multi" :class="{ 'with-all': selectAll }">
|
|
|
|
|
<a-select
|
|
|
|
|
mode="multiple"
|
|
|
|
|
allow-clear
|
|
|
|
|
show-search
|
2026-11-10 17:39:51 +03:30
|
|
|
:value="selectValue"
|
2026-11-09 21:30:05 +03:30
|
|
|
:options="options"
|
|
|
|
|
:disabled="disabled"
|
|
|
|
|
:placeholder="emptyLabel"
|
2026-11-10 17:39:51 +03:30
|
|
|
:filter-option="filterOption"
|
2026-11-09 21:30:05 +03:30
|
|
|
:max-tag-count="tagLimit"
|
2026-11-10 17:39:51 +03:30
|
|
|
:max-tag-placeholder="selectAll && allPicked ? tagPlaceholder : undefined"
|
2026-11-09 21:30:05 +03:30
|
|
|
style="width: 100%"
|
|
|
|
|
@update:value="onUpdate"
|
|
|
|
|
/>
|
|
|
|
|
<a-button v-if="selectAll" :disabled="disabled || allPicked || !users.length" @click="pickAll">
|
|
|
|
|
همه
|
|
|
|
|
</a-button>
|
|
|
|
|
</div>
|
2026-10-05 17:49:17 +03:30
|
|
|
</template>
|
2026-11-09 21:30:05 +03:30
|
|
|
|
|
|
|
|
<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>
|