Improve assignee and person pickers with search, chips and inline person creation

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-05 17:49:17 +03:30
co-authored by Cursor
parent 3296b1c209
commit df8fac187e
6 changed files with 606 additions and 75 deletions
+355
View File
@@ -0,0 +1,355 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
type PickUser = { id: string; fullName: string };
const props = withDefaults(
defineProps<{
modelValue: string[];
users: PickUser[];
disabled?: boolean;
/** حداقل تعداد انتخاب (حذف آخرین نفر غیرفعال می‌شود) */
min?: number;
addLabel?: string;
emptyLabel?: string;
}>(),
{ disabled: false, min: 0, addLabel: 'افزودن', emptyLabel: 'کسی انتخاب نشده' },
);
const emit = defineEmits<{
'update:modelValue': [ids: string[]];
/** پس از بستن لیست یا حذف یک نفر، اگر انتخاب تغییر کرده باشد */
change: [ids: string[]];
}>();
const open = ref(false);
const query = ref('');
const root = ref<HTMLElement | null>(null);
const searchInput = ref<HTMLInputElement | null>(null);
let snapshot: string[] = [];
const byId = computed(() => new Map(props.users.map((u) => [u.id, u])));
const selected = computed(() =>
props.modelValue.map((id) => byId.value.get(id) ?? { id, fullName: '—' }),
);
const filtered = computed(() => {
const q = query.value.trim().toLowerCase();
if (!q) return props.users;
return props.users.filter((u) => u.fullName.toLowerCase().includes(q));
});
const canRemove = computed(() => props.modelValue.length > props.min);
function sameSet(a: string[], b: string[]) {
return a.length === b.length && a.every((id) => b.includes(id));
}
function openList() {
if (props.disabled) return;
snapshot = [...props.modelValue];
query.value = '';
open.value = true;
void nextTick(() => searchInput.value?.focus());
}
function closeList() {
if (!open.value) return;
open.value = false;
if (!sameSet(snapshot, props.modelValue)) emit('change', [...props.modelValue]);
}
function toggle(id: string) {
const has = props.modelValue.includes(id);
if (has && !canRemove.value) return;
const next = has ? props.modelValue.filter((x) => x !== id) : [...props.modelValue, id];
emit('update:modelValue', next);
}
function remove(id: string) {
if (props.disabled || !canRemove.value) return;
const next = props.modelValue.filter((x) => x !== id);
emit('update:modelValue', next);
if (!open.value) emit('change', next);
}
function onDocClick(e: MouseEvent) {
if (open.value && root.value && !root.value.contains(e.target as Node)) closeList();
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') closeList();
}
onMounted(() => document.addEventListener('mousedown', onDocClick));
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocClick));
</script>
<template>
<div ref="root" class="ums" :class="{ disabled }" @keydown="onKey">
<div class="chips">
<span v-for="u in selected" :key="u.id" class="chip">
<UserAvatar :name="u.fullName" :size="22" />
<span class="chip-name">{{ u.fullName }}</span>
<button
v-if="canRemove && !disabled"
type="button"
class="chip-x"
:aria-label="`حذف ${u.fullName}`"
@click.stop="remove(u.id)"
>
<UiIcon name="x" :size="12" />
</button>
</span>
<span v-if="!selected.length" class="empty">{{ emptyLabel }}</span>
<button
type="button"
class="add"
:disabled="disabled"
:aria-expanded="open"
@click.stop="open ? closeList() : openList()"
>
<UiIcon name="user-plus" :size="14" />
{{ addLabel }}
</button>
</div>
<div v-if="open" class="pop" @click.stop>
<label class="search">
<UiIcon name="search" :size="14" />
<input ref="searchInput" v-model="query" type="search" placeholder="جستجوی نام…" />
</label>
<ul class="list">
<li v-for="u in filtered" :key="u.id">
<button
type="button"
class="row"
:class="{ on: modelValue.includes(u.id) }"
@click="toggle(u.id)"
>
<span class="check" aria-hidden="true">
<UiIcon v-if="modelValue.includes(u.id)" name="check-plain" :size="12" />
</span>
<UserAvatar :name="u.fullName" :size="26" />
<span class="row-name">{{ u.fullName }}</span>
</button>
</li>
<li v-if="!filtered.length" class="none">کاربری پیدا نشد.</li>
</ul>
<div class="foot">
<span>{{ modelValue.length.toLocaleString('fa-IR') }} نفر انتخاب شده</span>
<button type="button" class="done" @click="closeList">تأیید</button>
</div>
</div>
</div>
</template>
<style scoped>
.ums {
position: relative;
width: 100%;
min-width: 0;
}
.chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.18rem 0.25rem;
padding-inline-end: 0.5rem;
border-radius: 999px;
background: #f0fdfa;
border: 1px solid #ccfbf1;
color: #0f172a;
font-size: 0.82rem;
font-weight: 600;
max-width: 100%;
}
.chip-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chip-x {
width: 20px;
height: 20px;
border: 0;
border-radius: 999px;
background: transparent;
color: #64748b;
display: grid;
place-items: center;
cursor: pointer;
padding: 0;
}
.chip-x:hover {
background: #fee2e2;
color: #b91c1c;
}
.empty {
color: #94a3b8;
font-size: 0.82rem;
font-weight: 500;
}
.add {
display: inline-flex;
align-items: center;
gap: 0.3rem;
height: 30px;
padding: 0 0.7rem;
border: 1px dashed #5eead4;
border-radius: 999px;
background: #fff;
color: #0f766e;
font: inherit;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
}
.add:hover:not(:disabled) {
background: #f0fdfa;
}
.add:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pop {
position: absolute;
top: calc(100% + 6px);
inset-inline-start: 0;
z-index: 30;
width: min(18rem, calc(100vw - 2rem));
background: #fff;
border-radius: var(--radius);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.16);
border: 1px solid #f1f5f9;
display: grid;
overflow: hidden;
}
.search {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0.5rem;
padding: 0 0.6rem;
height: 2.2rem;
border: 1px solid #e2e8f0;
border-radius: 10px;
color: #94a3b8;
}
.search input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
font: inherit;
font-size: 0.85rem;
background: transparent;
color: #0f172a;
}
.list {
list-style: none;
margin: 0;
padding: 0 0.35rem 0.35rem;
max-height: 15rem;
overflow-y: auto;
}
.row {
width: 100%;
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.42rem 0.5rem;
border: 0;
border-radius: 10px;
background: none;
font: inherit;
font-size: 0.86rem;
font-weight: 500;
color: #0f172a;
text-align: start;
cursor: pointer;
}
.row:hover {
background: #f8fafc;
}
.row.on {
background: #f0fdfa;
font-weight: 700;
}
.check {
width: 18px;
height: 18px;
flex-shrink: 0;
border-radius: 5px;
border: 1.5px solid #cbd5e1;
display: grid;
place-items: center;
color: #fff;
}
.row.on .check {
background: #14b8a6;
border-color: #14b8a6;
}
.row-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.none {
padding: 0.75rem;
color: #94a3b8;
font-size: 0.84rem;
text-align: center;
}
.foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.65rem;
border-top: 1px solid #f1f5f9;
font-size: 0.78rem;
color: #64748b;
}
.done {
border: 0;
border-radius: 8px;
background: #14b8a6;
color: #fff;
font: inherit;
font-size: 0.8rem;
font-weight: 700;
padding: 0.35rem 0.9rem;
cursor: pointer;
}
</style>
+16 -6
View File
@@ -1,3 +1,7 @@
<script lang="ts">
let openModalCount = 0;
</script>
<script setup lang="ts">
import { onUnmounted, watch } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
@@ -24,16 +28,22 @@ const emit = defineEmits<{
close: [];
}>();
let locked = false;
function setLock(next: boolean) {
if (next === locked) return;
locked = next;
openModalCount = Math.max(0, openModalCount + (next ? 1 : -1));
document.body.style.overflow = openModalCount > 0 ? 'hidden' : '';
}
watch(
() => props.open,
(open) => {
document.body.style.overflow = open ? 'hidden' : '';
},
(open) => setLock(open),
{ immediate: true },
);
onUnmounted(() => {
document.body.style.overflow = '';
});
onUnmounted(() => setLock(false));
function isMobileViewport() {
return typeof window !== 'undefined' && window.matchMedia('(max-width: 900px)').matches;
+20 -32
View File
@@ -9,6 +9,7 @@ import {
} from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
import UserMultiSelect from '@/components/UserMultiSelect.vue';
import ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.vue';
import ActivityPostponeSheet from '@/components/activities/ActivityPostponeSheet.vue';
import type { ActivityDto, ActivityPriority, ActivityType, OrgUserDto } from '@mento/shared';
@@ -288,26 +289,20 @@ onMounted(() => {
</dt>
<dd>{{ dueLabel }}</dd>
</div>
<div>
<div class="assignees-row">
<dt>
<UiIcon name="user" :size="15" />
مسئولان
</dt>
<dd class="person">
<template v-if="canReassign">
<select
v-model="assignUserIds"
class="assign-select multi"
multiple
:disabled="saving"
@change="reassign"
>
<option v-for="u in users" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</select>
<small class="assign-hint">با Ctrl چند نفر را انتخاب کنید</small>
</template>
<UserMultiSelect
v-if="canReassign"
v-model="assignUserIds"
:users="users"
:disabled="saving"
:min="1"
@change="reassign"
/>
<template v-else>
{{ assigneeNames }}
</template>
@@ -610,27 +605,20 @@ dd {
gap: 0.45rem;
}
.assign-select {
min-height: 2.2rem;
min-width: 10rem;
max-width: 100%;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 0 0.55rem;
font: inherit;
background: #fff;
.assignees-row {
align-items: flex-start;
flex-wrap: wrap;
}
.assign-select.multi {
min-height: 5.5rem;
padding: 0.35rem;
.assignees-row dt {
padding-top: 0.3rem;
flex-shrink: 0;
}
.assign-hint {
display: block;
margin-top: 0.3rem;
color: #94a3b8;
font-size: 0.75rem;
.assignees-row dd {
flex: 1 1 16rem;
min-width: 0;
font-weight: 500;
}
.flag.low,
+194 -14
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { computed, nextTick, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
@@ -17,10 +17,12 @@ import { appendSpokenText } from '@/lib/spoken-text';
import UiIcon from '@/components/UiIcon.vue';
import { brand } from '@mento/shared';
import UserAvatar from '@/components/UserAvatar.vue';
import PersonFormView from '@/views/PersonFormView.vue';
import {
UiBadge,
UiButton,
UiInput,
UiModal,
UiPageHeader,
UiTextarea,
} from '@/components/ui';
@@ -87,6 +89,81 @@ const reminderNotifySms = ref(false);
const typeOpen = ref(false);
const assigneeOpen = ref(false);
const personOpen = ref(false);
const personQuery = ref('');
const personResults = ref<PersonDto[] | null>(null);
const personSearching = ref(false);
const personSearchInput = ref<HTMLInputElement | null>(null);
const newPersonOpen = ref(false);
let personSearchTimer: ReturnType<typeof setTimeout> | undefined;
let personSearchSeq = 0;
const personOptions = computed(() => personResults.value ?? people.value);
function rememberPerson(p: PersonDto) {
if (!people.value.some((x) => x.id === p.id)) people.value = [p, ...people.value];
}
async function ensurePersonLoaded(pid: string) {
if (!pid || people.value.some((p) => p.id === pid)) return;
try {
const { data } = await api.get<PersonDto>(`/people/${pid}`);
rememberPerson(data);
} catch {
/* person may have been removed */
}
}
function togglePersonMenu() {
personOpen.value = !personOpen.value;
if (personOpen.value) {
typeOpen.value = false;
assigneeOpen.value = false;
void nextTick(() => personSearchInput.value?.focus());
}
}
function pickPerson(p: PersonDto) {
rememberPerson(p);
personId.value = p.id;
personOpen.value = false;
}
function openNewPerson() {
personOpen.value = false;
newPersonOpen.value = true;
}
function onPersonCreated(p?: PersonDto) {
newPersonOpen.value = false;
if (!p) return;
personQuery.value = '';
personResults.value = null;
pickPerson(p);
}
watch(personQuery, (raw) => {
if (personSearchTimer) clearTimeout(personSearchTimer);
const q = raw.trim();
if (!q) {
personResults.value = null;
personSearching.value = false;
return;
}
personSearching.value = true;
const seq = ++personSearchSeq;
personSearchTimer = setTimeout(async () => {
try {
const { data } = await api.get<PaginatedResponse<PersonDto>>('/people', {
params: { q, limit: 30 },
});
if (seq === personSearchSeq) personResults.value = data.items;
} catch {
if (seq === personSearchSeq) personResults.value = [];
} finally {
if (seq === personSearchSeq) personSearching.value = false;
}
}, 300);
});
const typeOptions: Array<{
value: ActivityType;
@@ -221,6 +298,7 @@ async function load() {
: '';
reminderNotifyApp.value = data.reminderNotifyApp ?? true;
reminderNotifySms.value = data.reminderNotifySms ?? false;
if (data.person?.id) await ensurePersonLoaded(data.person.id);
personId.value = data.person?.id ?? '';
dealId.value = data.deal?.id ?? '';
assigneeIds.value = data.assignees?.length
@@ -236,7 +314,10 @@ async function load() {
const incomingPerson =
props.initialPersonId ||
(typeof route.query.personId === 'string' ? route.query.personId : '');
if (incomingPerson) personId.value = incomingPerson;
if (incomingPerson) {
await ensurePersonLoaded(incomingPerson);
personId.value = incomingPerson;
}
const incomingType = props.initialType || route.query.type;
if (
typeof incomingType === 'string' &&
@@ -563,7 +644,7 @@ watch([id, isNew], () => void load(), { immediate: true });
<div class="card">
<div class="field">
<label>شخص / مشتری مرتبط <i>*</i></label>
<button class="picker person-pick" type="button" @click.stop="personOpen = !personOpen">
<button class="picker person-pick" type="button" @click.stop="togglePersonMenu">
<UserAvatar :name="selectedPerson?.fullName || 'مشتری'" src="" :size="32" />
<span class="who">
<b :class="{ ghost: !selectedPerson }">{{ selectedPerson?.fullName || 'انتخاب کنید' }}</b>
@@ -571,17 +652,41 @@ watch([id, isNew], () => void load(), { immediate: true });
</span>
<UiIcon name="chevron" :size="16" />
</button>
<ul v-if="personOpen" class="menu">
<li v-for="p in people" :key="p.id">
<button type="button" @click="personId = p.id; personOpen = false">
<UserAvatar :name="p.fullName" src="" :size="28" />
<span class="who">
<b>{{ p.fullName }}</b>
<small>{{ p.company?.name || '' }}</small>
</span>
</button>
</li>
</ul>
<div v-if="personOpen" class="menu person-menu" @click.stop>
<label class="person-search">
<UiIcon name="search" :size="15" />
<input
ref="personSearchInput"
v-model="personQuery"
type="search"
placeholder="جستجوی نام، موبایل یا کد…"
@keydown.esc="personOpen = false"
/>
</label>
<ul class="person-list">
<li v-for="p in personOptions" :key="p.id">
<button
type="button"
:class="{ on: p.id === personId }"
@click="pickPerson(p)"
>
<UserAvatar :name="p.fullName" src="" :size="28" />
<span class="who">
<b>{{ p.fullName }}</b>
<small>{{ p.company?.name || p.mobile || '' }}</small>
</span>
</button>
</li>
<li v-if="personSearching" class="person-empty">در حال جستجو…</li>
<li v-else-if="!personOptions.length" class="person-empty">
شخصی پیدا نشد.
</li>
</ul>
<button type="button" class="person-add" @click="openNewPerson">
<UiIcon name="user-plus" :size="16" />
{{ personQuery.trim() ? `افزودن «${personQuery.trim()}» به‌عنوان شخص جدید` : 'افزودن شخص جدید' }}
</button>
</div>
</div>
<div v-if="personDeals.length > 1" class="field deal-field">
<label>فرصت مرتبط</label>
@@ -745,6 +850,16 @@ watch([id, isNew], () => void load(), { immediate: true });
</UiButton>
<UiButton variant="ghost" type="button" :disabled="saving" @click="cancel">انصراف</UiButton>
</div>
<UiModal :open="newPersonOpen" title="شخص جدید" nest @close="newPersonOpen = false">
<PersonFormView
v-if="newPersonOpen"
embedded
:initial-query="personQuery"
@saved="onPersonCreated"
@cancel="newPersonOpen = false"
/>
</UiModal>
</section>
</template>
@@ -1041,6 +1156,71 @@ watch([id, isNew], () => void load(), { immediate: true });
background: #f8fafc;
}
.person-menu {
max-height: none;
overflow: visible;
display: grid;
gap: 0.3rem;
padding: 0.45rem;
}
.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);
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;
}
.person-list button.on {
background: #f0fdfa;
}
.person-empty {
padding: 0.7rem;
text-align: center;
color: #94a3b8;
font-size: 0.85rem;
}
.menu .person-add {
border-top: 1px solid #f1f5f9;
border-radius: 0 0 var(--radius) var(--radius);
color: #0f766e;
font-weight: 700;
font-size: 0.86rem;
}
.menu .person-add:hover {
background: #f0fdfa;
}
.toggles {
display: grid;
gap: 0.85rem;
+4 -21
View File
@@ -12,6 +12,7 @@ import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
import UserMultiSelect from '@/components/UserMultiSelect.vue';
import { UiButton, UiField, UiInput, UiPageHeader, UiSelect } from '@/components/ui';
import type {
CompanyDto,
@@ -628,18 +629,9 @@ onMounted(load);
</ul>
</div>
<div class="field">
<label for="deal-assignees">مسئولان مشترک</label>
<select
id="deal-assignees"
v-model="selectedAssigneeIds"
multiple
class="multi"
>
<option v-for="u in users" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</select>
<small class="hint">علاوه بر مسئول اصلی؛ با Ctrl چند نفر را انتخاب کنید.</small>
<label>مسئولان مشترک</label>
<UserMultiSelect v-model="selectedAssigneeIds" :users="users" />
<small class="hint">علاوه بر مسئول اصلی.</small>
</div>
</div>
</section>
@@ -1340,15 +1332,6 @@ h1 {
gap: 0.45rem;
}
select.multi {
min-height: 110px;
padding: 0.4rem;
width: 100%;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
background: #fff;
}
.hint {
color: #64748b;
font-size: 0.8rem;
+17 -2
View File
@@ -34,10 +34,12 @@ const props = withDefaults(
defineProps<{
embedded?: boolean;
entityId?: string;
/** متن جستجو برای پیش‌پر کردن نام یا موبایل شخص جدید */
initialQuery?: string;
}>(),
{ embedded: false },
);
const emit = defineEmits<{ saved: []; cancel: [] }>();
const emit = defineEmits<{ saved: [person?: PersonDto]; cancel: [] }>();
const route = useRoute();
const router = useRouter();
@@ -241,6 +243,8 @@ async function load() {
if (!isNew.value && id.value) {
const { data } = await api.get<PersonDto>(`/people/${id.value}`);
fill(data);
} else if (props.initialQuery?.trim()) {
prefillFromQuery(props.initialQuery.trim());
}
} catch {
error.value = 'بارگذاری فرم ناموفق بود.';
@@ -249,6 +253,17 @@ async function load() {
}
}
function prefillFromQuery(text: string) {
const digits = text.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d))).replace(/[\s-]/g, '');
if (/^\+?\d{5,}$/.test(digits)) {
mobile.value = digits;
return;
}
const [first, ...rest] = text.split(/\s+/);
firstName.value = first ?? '';
lastName.value = rest.join(' ');
}
function parseTags(raw: string): string[] {
return [
...new Set(
@@ -287,7 +302,7 @@ async function submit() {
if (isNew.value) {
const { data } = await api.post<PersonDto>('/people', payload());
if (props.embedded) {
emit('saved');
emit('saved', data);
return;
}
await router.replace({ name: 'person-detail', params: { id: data.id } });