Files
CRM_Montzar/apps/web/src/components/OrgUserPersonPick.vue
T

368 lines
7.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>