2026-11-12 12:59:49 +03:30
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed, nextTick, ref, watch } from 'vue';
|
|
|
|
|
|
import type { OrgUserDto } from '@mento/shared';
|
|
|
|
|
|
import { toFaDigits } from '@/lib/jalali';
|
|
|
|
|
|
import { normalizeSearchText } from '@/lib/search';
|
|
|
|
|
|
import UiIcon from '@/components/UiIcon.vue';
|
|
|
|
|
|
import UserAvatar from '@/components/UserAvatar.vue';
|
|
|
|
|
|
|
|
|
|
|
|
const props = withDefaults(
|
|
|
|
|
|
defineProps<{
|
|
|
|
|
|
modelValue: string[];
|
|
|
|
|
|
users: Array<Pick<OrgUserDto, 'id' | 'fullName' | 'orgRoleName' | 'hasAvatar'>>;
|
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
|
emptyLabel?: string;
|
|
|
|
|
|
}>(),
|
|
|
|
|
|
{
|
|
|
|
|
|
disabled: false,
|
|
|
|
|
|
emptyLabel: 'انتخاب مسئول',
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
'update:modelValue': [ids: string[]];
|
|
|
|
|
|
change: [ids: string[]];
|
|
|
|
|
|
}>();
|
|
|
|
|
|
|
|
|
|
|
|
const open = ref(false);
|
|
|
|
|
|
const query = ref('');
|
|
|
|
|
|
const searchInput = ref<HTMLInputElement | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const selected = computed(() =>
|
|
|
|
|
|
props.modelValue
|
|
|
|
|
|
.map((id) => props.users.find((u) => u.id === id))
|
|
|
|
|
|
.filter((u): u is NonNullable<typeof u> => Boolean(u)),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const label = computed(() => {
|
|
|
|
|
|
if (!selected.value.length) return props.emptyLabel;
|
|
|
|
|
|
if (selected.value.length === 1) return selected.value[0]!.fullName;
|
|
|
|
|
|
return `${selected.value[0]!.fullName} و ${toFaDigits(selected.value.length - 1)} نفر دیگر`;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const filtered = computed(() => {
|
|
|
|
|
|
const q = normalizeSearchText(query.value);
|
|
|
|
|
|
if (!q) return props.users;
|
|
|
|
|
|
return props.users.filter(
|
|
|
|
|
|
(u) =>
|
|
|
|
|
|
normalizeSearchText(u.fullName).includes(q) ||
|
|
|
|
|
|
normalizeSearchText(u.orgRoleName ?? '').includes(q),
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
function toggleMenu() {
|
|
|
|
|
|
if (props.disabled) return;
|
|
|
|
|
|
open.value = !open.value;
|
|
|
|
|
|
if (open.value) {
|
|
|
|
|
|
void nextTick(() => searchInput.value?.focus());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function close() {
|
|
|
|
|
|
open.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-12 19:07:36 +03:30
|
|
|
|
function setIds(next: string[]) {
|
|
|
|
|
|
emit('update:modelValue', next);
|
|
|
|
|
|
emit('change', next);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-12 12:59:49 +03:30
|
|
|
|
function toggle(userId: string) {
|
|
|
|
|
|
if (props.disabled) return;
|
|
|
|
|
|
const set = new Set(props.modelValue);
|
|
|
|
|
|
if (set.has(userId)) set.delete(userId);
|
|
|
|
|
|
else set.add(userId);
|
2026-11-12 19:07:36 +03:30
|
|
|
|
setIds([...set]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function remove(userId: string) {
|
|
|
|
|
|
if (props.disabled) return;
|
|
|
|
|
|
if (!props.modelValue.includes(userId)) return;
|
|
|
|
|
|
setIds(props.modelValue.filter((id) => id !== userId));
|
2026-11-12 12:59:49 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
watch(open, (isOpen) => {
|
|
|
|
|
|
if (!isOpen) query.value = '';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
defineExpose({ close });
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<div class="wrap" @click.stop>
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="picker person-pick"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
:disabled="disabled"
|
|
|
|
|
|
@click="toggleMenu"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="assignee-avatars">
|
|
|
|
|
|
<UserAvatar
|
|
|
|
|
|
v-for="user in selected.slice(0, 3)"
|
|
|
|
|
|
:key="user.id"
|
|
|
|
|
|
:name="user.fullName"
|
|
|
|
|
|
:user-id="user.id"
|
|
|
|
|
|
:has-avatar="user.hasAvatar"
|
|
|
|
|
|
:size="28"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<UserAvatar v-if="!selected.length" name="مسئول" :size="28" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span :class="{ ghost: !selected.length }">{{ label }}</span>
|
|
|
|
|
|
<UiIcon name="chevron" :size="16" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
2026-11-12 19:07:36 +03:30
|
|
|
|
<div v-if="selected.length" class="chips">
|
|
|
|
|
|
<span v-for="user in selected" :key="user.id" class="chip">
|
|
|
|
|
|
{{ user.fullName }}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="chip-x"
|
|
|
|
|
|
:disabled="disabled"
|
|
|
|
|
|
:aria-label="`حذف ${user.fullName}`"
|
|
|
|
|
|
@click="remove(user.id)"
|
|
|
|
|
|
>
|
|
|
|
|
|
×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-11-12 12:59:49 +03:30
|
|
|
|
<div v-if="open" class="menu person-menu multi-menu" @click.stop>
|
|
|
|
|
|
<label class="person-search">
|
|
|
|
|
|
<UiIcon name="search" :size="15" />
|
|
|
|
|
|
<input
|
|
|
|
|
|
ref="searchInput"
|
|
|
|
|
|
v-model="query"
|
|
|
|
|
|
type="search"
|
|
|
|
|
|
placeholder="جستجوی نام یا نقش…"
|
|
|
|
|
|
@keydown.esc="close"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<ul class="person-list">
|
|
|
|
|
|
<li v-for="user in filtered" :key="user.id">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="multi-option"
|
|
|
|
|
|
:class="{ on: modelValue.includes(user.id) }"
|
|
|
|
|
|
@click="toggle(user.id)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="check" :class="{ on: modelValue.includes(user.id) }">
|
|
|
|
|
|
<UiIcon v-if="modelValue.includes(user.id)" name="check-plain" :size="12" />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<UserAvatar
|
|
|
|
|
|
:name="user.fullName"
|
|
|
|
|
|
:user-id="user.id"
|
|
|
|
|
|
:has-avatar="user.hasAvatar"
|
|
|
|
|
|
:size="28"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span class="role-name">{{ user.fullName }}</span>
|
|
|
|
|
|
<small v-if="user.orgRoleName" class="role-count">{{ user.orgRoleName }}</small>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
<li v-if="!filtered.length" class="menu-empty">موردی پیدا نشد.</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.wrap {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
min-width: 12rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.picker {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
min-height: var(--control-h, 36px);
|
|
|
|
|
|
height: var(--control-h, 36px);
|
|
|
|
|
|
border: 1px solid #e2e8f0;
|
|
|
|
|
|
border-radius: var(--radius, 10px);
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.6rem;
|
|
|
|
|
|
padding: 0.4rem 0.7rem;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
text-align: start;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.picker:disabled {
|
|
|
|
|
|
opacity: 0.65;
|
|
|
|
|
|
cursor: not-allowed;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.picker .ghost {
|
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.picker :deep(.ico:last-child) {
|
|
|
|
|
|
margin-inline-start: auto;
|
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
transform: rotate(90deg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.assignee-avatars {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.assignee-avatars :deep(.avatar),
|
|
|
|
|
|
.assignee-avatars > * {
|
|
|
|
|
|
margin-inline-end: -0.35rem;
|
|
|
|
|
|
box-shadow: 0 0 0 2px #fff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-12 19:07:36 +03:30
|
|
|
|
.chips {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
|
margin-top: 0.4rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.chip {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.25rem;
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
padding: 0.15rem 0.35rem 0.15rem 0.55rem;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
background: #f0fdfa;
|
|
|
|
|
|
border: 1px solid #99f6e4;
|
|
|
|
|
|
color: #0f766e;
|
|
|
|
|
|
font-size: 0.78rem;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.chip-x {
|
|
|
|
|
|
appearance: none;
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
color: inherit;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
line-height: 1;
|
|
|
|
|
|
padding: 0 0.2rem;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.chip-x:hover:not(:disabled) {
|
|
|
|
|
|
background: #ccfbf1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.chip-x:disabled {
|
|
|
|
|
|
opacity: 0.5;
|
|
|
|
|
|
cursor: not-allowed;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-12 12:59:49 +03:30
|
|
|
|
.menu {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
inset-inline: 0;
|
|
|
|
|
|
top: calc(100% + 0.25rem);
|
|
|
|
|
|
z-index: 40;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
border-radius: var(--radius, 10px);
|
|
|
|
|
|
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.person-menu {
|
|
|
|
|
|
max-height: none;
|
|
|
|
|
|
overflow: visible;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
|
padding: 0.45rem;
|
|
|
|
|
|
border: 1px solid #e2e8f0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.person-search {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.45rem;
|
|
|
|
|
|
height: 2.4rem;
|
|
|
|
|
|
padding: 0 0.7rem;
|
|
|
|
|
|
border: 1px solid #e2e8f0;
|
|
|
|
|
|
border-radius: var(--radius, 10px);
|
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.person-search:focus-within {
|
|
|
|
|
|
border-color: #5eead4;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.person-search input {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
outline: none;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
font-size: 0.88rem;
|
|
|
|
|
|
color: #0f172a;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.person-list {
|
|
|
|
|
|
list-style: none;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
max-height: 15rem;
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.multi-menu .multi-option {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.55rem;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
background: none;
|
|
|
|
|
|
padding: 0.55rem 0.5rem;
|
|
|
|
|
|
border-radius: var(--radius, 10px);
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
text-align: start;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.multi-menu .multi-option:hover {
|
|
|
|
|
|
background: #f8fafc;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.multi-menu .multi-option.on {
|
|
|
|
|
|
background: #f0fdfa;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.multi-menu .check {
|
|
|
|
|
|
width: 18px;
|
|
|
|
|
|
height: 18px;
|
|
|
|
|
|
border: 1.5px solid #cbd5e1;
|
|
|
|
|
|
border-radius: 5px;
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.multi-menu .check.on {
|
|
|
|
|
|
background: #14b8a6;
|
|
|
|
|
|
border-color: #14b8a6;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.multi-menu .role-name {
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.multi-menu .role-count {
|
|
|
|
|
|
margin-inline-start: auto;
|
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
font-size: 0.72rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.menu-empty {
|
|
|
|
|
|
padding: 0.6rem;
|
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
font-size: 0.82rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|