Improve assignee and person pickers with search, chips and inline person creation
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
Reference in New Issue
Block a user