fix deal edit save; add new person from deal form; club and multi-select
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -14,7 +14,8 @@ 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 { UiButton, UiField, UiInput, UiModal, UiPageHeader, UiSelect } from '@/components/ui';
|
||||
import PersonFormView from '@/views/PersonFormView.vue';
|
||||
import type {
|
||||
CompanyDto,
|
||||
CreateDealRequest,
|
||||
@@ -22,6 +23,7 @@ import type {
|
||||
DealLineItemInput,
|
||||
OrganizationDto,
|
||||
OrgUserDto,
|
||||
PaginatedResponse,
|
||||
PersonDto,
|
||||
PipelineBoardDto,
|
||||
PipelineDto,
|
||||
@@ -72,6 +74,13 @@ const showOthers = ref(true);
|
||||
const productsOpen = ref(false);
|
||||
const openMenu = ref<MenuKey>('');
|
||||
const contactKind = ref<ContactKind>('person');
|
||||
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 people = ref<PersonDto[]>([]);
|
||||
const companies = ref<CompanyDto[]>([]);
|
||||
@@ -115,6 +124,84 @@ const nextDuration = ref(30);
|
||||
const selectedPerson = computed(() => people.value.find((p) => p.id === personId.value) ?? null);
|
||||
const selectedCompany = computed(() => companies.value.find((c) => c.id === companyId.value) ?? null);
|
||||
const selectedOwner = computed(() => users.value.find((u) => u.id === ownerId.value) ?? null);
|
||||
const personOptions = computed(() => personResults.value ?? people.value);
|
||||
const personSub = computed(() => {
|
||||
const p = selectedPerson.value;
|
||||
if (!p) return '';
|
||||
return [p.company?.name, p.mobile || p.phone].filter(Boolean).join(' · ');
|
||||
});
|
||||
|
||||
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() {
|
||||
const next = openMenu.value === 'person' ? '' : 'person';
|
||||
openMenu.value = next;
|
||||
if (next === 'person') {
|
||||
void nextTick(() => personSearchInput.value?.focus());
|
||||
}
|
||||
}
|
||||
|
||||
function pickPerson(p: PersonDto) {
|
||||
rememberPerson(p);
|
||||
personId.value = p.id;
|
||||
if (p.company?.id) companyId.value = p.company.id;
|
||||
openMenu.value = '';
|
||||
}
|
||||
|
||||
function clearPerson() {
|
||||
personId.value = '';
|
||||
openMenu.value = '';
|
||||
}
|
||||
|
||||
function openNewPerson() {
|
||||
openMenu.value = '';
|
||||
newPersonOpen.value = true;
|
||||
}
|
||||
|
||||
function onPersonCreated(p?: PersonDto) {
|
||||
newPersonOpen.value = false;
|
||||
if (!p) return;
|
||||
personQuery.value = '';
|
||||
personResults.value = null;
|
||||
contactKind.value = 'person';
|
||||
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 linesTotal = computed(() =>
|
||||
lines.value.reduce((sum, line) => {
|
||||
@@ -255,6 +342,7 @@ async function load() {
|
||||
personId.value = data.person?.id ?? '';
|
||||
companyId.value = data.company?.id ?? '';
|
||||
contactKind.value = data.person?.id || !data.company?.id ? 'person' : 'company';
|
||||
if (personId.value) await ensurePersonLoaded(personId.value);
|
||||
stageId.value = data.stageId;
|
||||
ownerId.value = data.owner?.id ?? auth.user?.id ?? '';
|
||||
selectedAssigneeIds.value = (data.assignees ?? []).map((a) => a.id);
|
||||
@@ -390,14 +478,15 @@ async function submit() {
|
||||
}
|
||||
await router.replace({ name: 'deal-detail', params: { id: data.id } });
|
||||
} else {
|
||||
const { pipelineId: _pipelineId, stageId: moveStageId, ...updateBody } = payload();
|
||||
await api.patch(`/deals/${id.value}`, {
|
||||
...payload(),
|
||||
...updateBody,
|
||||
lostReason: lostReason.value || null,
|
||||
lostNote: lostNote.value.trim() || null,
|
||||
closedAt: closedAt.value || null,
|
||||
});
|
||||
if (stageId.value) {
|
||||
await api.patch(`/deals/${id.value}/move`, { stageId: stageId.value });
|
||||
if (moveStageId) {
|
||||
await api.patch(`/deals/${id.value}/move`, { stageId: moveStageId });
|
||||
}
|
||||
if (props.embedded) {
|
||||
emit('saved');
|
||||
@@ -505,28 +594,75 @@ onMounted(load);
|
||||
|
||||
<div class="pair contacts">
|
||||
<div v-show="contactKind === 'person'" class="field">
|
||||
<label>شخص مرتبط <i>*</i></label>
|
||||
<button class="picker" type="button" @click.stop="toggleMenu('person')">
|
||||
<UserAvatar :name="selectedPerson?.fullName || 'شخص'" src="" :size="32" />
|
||||
<span :class="{ ghost: !selectedPerson }">
|
||||
{{ selectedPerson?.fullName || 'انتخاب کنید' }}
|
||||
<div class="label-row">
|
||||
<label>شخص مرتبط <i>*</i></label>
|
||||
<button type="button" class="person-add" @click.stop="openNewPerson">
|
||||
<UiIcon name="user-plus" :size="14" />
|
||||
افزودن شخص جدید
|
||||
</button>
|
||||
</div>
|
||||
<button class="picker person-pick" type="button" @click.stop="togglePersonMenu">
|
||||
<span class="person-ico" :class="{ on: selectedPerson }">
|
||||
<UiIcon name="user" :size="16" />
|
||||
</span>
|
||||
<span class="who">
|
||||
<b :class="{ ghost: !selectedPerson }">
|
||||
{{ selectedPerson?.fullName || 'انتخاب کنید' }}
|
||||
</b>
|
||||
<small v-if="personSub">{{ personSub }}</small>
|
||||
</span>
|
||||
<UiIcon name="chevron" :size="16" />
|
||||
</button>
|
||||
<ul v-if="openMenu === 'person'" class="menu">
|
||||
<li>
|
||||
<button type="button" @click="personId = ''; openMenu = ''">بدون شخص</button>
|
||||
</li>
|
||||
<li v-for="p in people" :key="p.id">
|
||||
<button type="button" @click="personId = p.id; openMenu = ''">
|
||||
<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="openMenu === 'person'" 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="openMenu = ''"
|
||||
/>
|
||||
</label>
|
||||
<ul class="person-list">
|
||||
<li v-if="personId">
|
||||
<button type="button" class="person-row" @click="clearPerson">
|
||||
<span class="row-text">
|
||||
<b>بدون شخص</b>
|
||||
<small>حذف شخص انتخابشده</small>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li v-for="p in personOptions" :key="p.id">
|
||||
<button
|
||||
type="button"
|
||||
class="person-row"
|
||||
:class="{ on: p.id === personId }"
|
||||
@click="pickPerson(p)"
|
||||
>
|
||||
<span class="row-text">
|
||||
<b>{{ p.fullName }}</b>
|
||||
<small v-if="p.company?.name">{{ p.company.name }}</small>
|
||||
</span>
|
||||
<span v-if="p.mobile || p.phone" class="row-phone" dir="ltr">
|
||||
{{ p.mobile || p.phone }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="personSearching" class="person-empty">در حال جستجو…</li>
|
||||
<li v-else-if="!personOptions.length" class="person-empty">
|
||||
شخصی پیدا نشد.
|
||||
<button
|
||||
v-if="personQuery.trim()"
|
||||
type="button"
|
||||
class="empty-add"
|
||||
@click="openNewPerson"
|
||||
>
|
||||
افزودن «{{ personQuery.trim() }}» بهعنوان شخص جدید
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="contactKind === 'company'" class="field desktop-also">
|
||||
@@ -833,15 +969,27 @@ onMounted(load);
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
<p v-if="embedded && error" class="error embed-error">{{ error }}</p>
|
||||
|
||||
<!-- Embedded Modal Actions (Fixed at bottom) -->
|
||||
<div v-if="embedded" class="embed-actions">
|
||||
<UiButton variant="primary" :disabled="saving" @click="submit">
|
||||
<UiButton variant="primary" type="button" :disabled="saving" :loading="saving" @click="submit">
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره معامله' }}
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" :disabled="saving" @click="cancel">
|
||||
<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>
|
||||
|
||||
@@ -1154,6 +1302,7 @@ h1 {
|
||||
.who {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
@@ -1161,11 +1310,171 @@ h1 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.who b.ghost {
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.who small {
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.person-add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
color: #0f766e;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.person-add:hover {
|
||||
background: #f0fdfa;
|
||||
}
|
||||
|
||||
.person-ico {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.person-ico.on {
|
||||
background: #ccfbf1;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.person-menu {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding: 0.45rem;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.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;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.person-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 14rem;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.person-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0.55rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.person-row:hover,
|
||||
.person-row.on {
|
||||
background: #f0fdfa;
|
||||
}
|
||||
|
||||
.row-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.row-text b {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-text small {
|
||||
color: #94a3b8;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.row-phone {
|
||||
font-size: 0.74rem;
|
||||
color: #64748b;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.person-empty {
|
||||
padding: 0.7rem 0.55rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.82rem;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-add {
|
||||
border: 0;
|
||||
background: #ecfdf5;
|
||||
color: #0f766e;
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.6rem;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.embed-error {
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user