368 lines
7.7 KiB
Vue
368 lines
7.7 KiB
Vue
<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;
|
||
}
|
||
|
||
function setIds(next: string[]) {
|
||
emit('update:modelValue', next);
|
||
emit('change', next);
|
||
}
|
||
|
||
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);
|
||
setIds([...set]);
|
||
}
|
||
|
||
function remove(userId: string) {
|
||
if (props.disabled) return;
|
||
if (!props.modelValue.includes(userId)) return;
|
||
setIds(props.modelValue.filter((id) => id !== userId));
|
||
}
|
||
|
||
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>
|
||
|
||
<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>
|
||
|
||
<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;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.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>
|