fix deal edit save; add new person from deal form; club and multi-select

This commit is contained in:
2026-10-12 19:23:07 +03:30
parent faac26d07d
commit cba4ad6a49
+335 -26
View File
@@ -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;