Align Leads header with Contacts and add phone-contact lead import.

Leads actions mirror people import/export, and create-lead can fill fields from device contacts like Person form.
This commit is contained in:
2026-09-27 19:51:02 +03:30
parent 05f205cadf
commit 9edbb92b13
2 changed files with 441 additions and 103 deletions
+33 -12
View File
@@ -15,9 +15,14 @@ import {
type PhoneContactDraft,
} from '@/lib/phone-contacts';
defineProps<{
disabled?: boolean;
}>();
const props = withDefaults(
defineProps<{
disabled?: boolean;
/** people = اشخاص | leads = لیدها */
target?: 'people' | 'leads';
}>(),
{ target: 'people' },
);
const emit = defineEmits<{
start: [];
@@ -171,15 +176,30 @@ async function importSelected() {
let failed = 0;
try {
for (const row of rows) {
const body: CreatePersonRequest = {
firstName: row.firstName,
lastName: row.lastName || undefined,
mobile: row.mobile,
phone: row.phone,
email: row.email,
};
try {
await api.post<PersonDto>('/people', body);
if (props.target === 'leads') {
const mobile = row.mobile || row.phone;
if (!mobile) {
failed += 1;
continue;
}
await api.post('/leads', {
title: row.fullName,
contactName: row.fullName,
mobile,
phone: row.phone && row.phone !== mobile ? row.phone : null,
source: 'مخاطبین گوشی',
});
} else {
const body: CreatePersonRequest = {
firstName: row.firstName,
lastName: row.lastName || undefined,
mobile: row.mobile,
phone: row.phone,
email: row.email,
};
await api.post<PersonDto>('/people', body);
}
created += 1;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 409) {
@@ -315,7 +335,8 @@ async function importSelected() {
@click="importSelected"
>
<UiIcon name="user-plus" :size="16" />
ورود {{ toFaDigits(selectedCount) }} نفر
ورود {{ toFaDigits(selectedCount) }}
{{ target === 'leads' ? 'لید' : 'نفر' }}
</UiButton>
</template>
</template>
+408 -91
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
@@ -16,6 +16,15 @@ import {
UiPageHeader,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import PhoneContactsImport from '@/components/PhoneContactsImport.vue';
import {
filterPhoneContacts,
isContactPickerSupported,
parseVCardFile,
pickPhoneContacts,
sortPhoneContacts,
type PhoneContactDraft,
} from '@/lib/phone-contacts';
import type {
LeadDto,
LeadImportJobDto,
@@ -50,7 +59,6 @@ const cityOptions = ref<string[]>([]);
const sourceOptions = ref<string[]>([]);
const selected = ref<Set<string>>(new Set());
const sortOpen = ref(false);
const moreOpen = ref(false);
const rowMenuId = ref<string | null>(null);
const assignOpen = ref(false);
const stageOpen = ref(false);
@@ -71,8 +79,19 @@ const createForm = ref({
stageId: '',
assigneeId: '',
});
const contactPickerSupported = isContactPickerSupported();
const pickingContact = ref(false);
const contactFileInput = ref<HTMLInputElement | null>(null);
const sourceOpen = ref(false);
const pickOpen = ref(false);
const pickDrafts = ref<PhoneContactDraft[]>([]);
const pickQuery = ref('');
let importTimer: ReturnType<typeof setInterval> | null = null;
const visiblePickDrafts = computed(() =>
filterPhoneContacts(pickDrafts.value, pickQuery.value),
);
const page = computed(() => {
const raw = Number(route.query.page);
return Number.isFinite(raw) && raw > 0 ? raw : 1;
@@ -222,7 +241,6 @@ function lastActivity(lead: LeadDto) {
function closePopups() {
sortOpen.value = false;
moreOpen.value = false;
rowMenuId.value = null;
assignOpen.value = false;
stageOpen.value = false;
@@ -353,9 +371,118 @@ function openCreate() {
stageId: stages.value[0]?.id ?? '',
assigneeId: '',
};
closePick();
closeSource();
createOpen.value = true;
}
function applyContactDraft(draft: PhoneContactDraft) {
if (draft.mobile) {
createForm.value.mobile = draft.mobile;
} else if (draft.phone && !createForm.value.mobile.trim()) {
createForm.value.mobile = draft.phone;
}
if (!createForm.value.contactName.trim() && draft.fullName) {
createForm.value.contactName = draft.fullName;
}
if (!createForm.value.title.trim() && draft.fullName) {
createForm.value.title = draft.fullName;
}
if (!createForm.value.source.trim()) {
createForm.value.source = 'مخاطبین گوشی';
}
}
function draftLine(row: PhoneContactDraft) {
return [row.mobile, row.phone, row.email].filter(Boolean).join(' · ') || 'بدون شماره/ایمیل';
}
function closePick() {
pickOpen.value = false;
pickDrafts.value = [];
pickQuery.value = '';
}
function closeSource() {
sourceOpen.value = false;
}
function choosePick(row: PhoneContactDraft) {
applyContactDraft(row);
closePick();
}
async function openPickList(rows: PhoneContactDraft[]) {
pickDrafts.value = sortPhoneContacts(rows);
pickQuery.value = '';
sourceOpen.value = false;
pickOpen.value = false;
await nextTick();
pickOpen.value = true;
}
function fillFromPhoneContact() {
if (pickingContact.value || createBusy.value) return;
createError.value = '';
if (contactPickerSupported) {
sourceOpen.value = true;
return;
}
contactFileInput.value?.click();
}
async function pickFromDevice() {
if (pickingContact.value || createBusy.value) return;
pickingContact.value = true;
createError.value = '';
sourceOpen.value = false;
try {
const rows = await pickPhoneContacts({ multiple: true });
if (!rows.length) {
createError.value = 'مخاطبی انتخاب نشد.';
return;
}
await openPickList(rows);
} catch (e) {
const name = e instanceof DOMException ? e.name : '';
const message = e instanceof Error ? e.message : '';
if (name === 'AbortError') return;
if (message === 'CONTACT_PICKER_UNSUPPORTED' || name === 'NotSupportedError') {
contactFileInput.value?.click();
return;
}
createError.value = 'انتخاب مخاطب از گوشی ناموفق بود.';
} finally {
pickingContact.value = false;
}
}
function pickFromVcf() {
sourceOpen.value = false;
contactFileInput.value?.click();
}
async function onContactVcfFile(ev: Event) {
const el = ev.target as HTMLInputElement;
const file = el.files?.[0];
el.value = '';
if (!file) return;
pickingContact.value = true;
createError.value = '';
try {
const rows = await parseVCardFile(file);
if (!rows.length) {
createError.value = 'مخاطبی در فایل پیدا نشد.';
return;
}
await openPickList(rows);
} catch {
createError.value = 'خواندن فایل مخاطب (.vcf) ناموفق بود.';
} finally {
pickingContact.value = false;
}
}
async function submitCreate() {
createError.value = '';
if (!createForm.value.title.trim() || !createForm.value.mobile.trim()) {
@@ -437,7 +564,6 @@ async function archiveSelected() {
await api.post('/leads/archive', { leadIds: selectedIds.value });
okMsg.value = `${toFaDigits(selectedIds.value.length)} لید بایگانی شد.`;
selected.value = new Set();
moreOpen.value = false;
await load();
} catch {
error.value = 'بایگانی ناموفق بود.';
@@ -517,6 +643,31 @@ async function onImportFile(ev: Event) {
}
}
function onPhoneImportStart() {
busy.value = true;
okMsg.value = '';
error.value = '';
}
async function onPhoneImportDone(data: {
created: number;
skipped: number;
failed: number;
}) {
okMsg.value = `از گوشی وارد شد: ${toFaDigits(data.created)} · تکراری: ${toFaDigits(data.skipped)}${
data.failed ? ` · ناموفق: ${toFaDigits(data.failed)}` : ''
}`;
await load();
}
function onPhoneImportFail(message: string) {
error.value = message;
}
function onPhoneImportSettled() {
busy.value = false;
}
function goDetail(id: string) {
void router.push({ name: 'lead-detail', params: { id } });
}
@@ -561,68 +712,44 @@ onBeforeUnmount(stopImportPoll);
lead="مدیریت و پیگیری تمام لیدهای ورودی"
>
<template #actions>
<div class="header-actions">
<div class="pop">
<button
class="more-btn"
type="button"
title="بیشتر"
@click.stop="moreOpen = !moreOpen; sortOpen = false"
>
<UiIcon name="dots" :size="18" />
</button>
<ul v-if="moreOpen" class="menu">
<li>
<RouterLink :to="{ name: 'leads-board' }" @click="moreOpen = false">
کاریز لیدها
</RouterLink>
</li>
<li v-if="isAdmin">
<RouterLink :to="{ name: 'lead-stages' }" @click="moreOpen = false">
تنظیم وضعیت‌ها
</RouterLink>
</li>
<li v-if="selectedIds.length">
<button type="button" @click.stop="archiveSelected">
بایگانی انتخاب‌شده
</button>
</li>
</ul>
</div>
<UiButton
v-if="isAdmin"
variant="ghost"
size="sm"
class="excel-btn"
:disabled="busy"
@click.stop="exportExcel"
>
<UiIcon name="document" :size="16" />
خروجی Excel
<UiButton variant="ghost" size="sm" :disabled="busy" @click.stop="exportExcel">
<span class="btn-full">خروجی اکسل</span>
<span class="btn-short">خروجی</span>
</UiButton>
<UiButton
variant="ghost"
size="sm"
:disabled="busy"
@click.stop="fileInput?.click()"
>
<span class="btn-full">ورود از اکسل</span>
<span class="btn-short">ورود</span>
</UiButton>
<input
ref="fileInput"
type="file"
accept=".xlsx,.xls,.csv"
hidden
@change="onImportFile"
/>
<PhoneContactsImport
target="leads"
:disabled="busy"
@start="onPhoneImportStart"
@done="onPhoneImportDone"
@fail="onPhoneImportFail"
@settled="onPhoneImportSettled"
>
<UiButton variant="ghost" size="sm" :disabled="busy">
<UiIcon name="phone" :size="15" />
<span class="btn-full">از مخاطبین گوشی</span>
<span class="btn-short">گوشی</span>
</UiButton>
<UiButton
v-if="isAdmin"
variant="ghost"
size="sm"
class="excel-btn"
:disabled="busy"
@click.stop="fileInput?.click()"
>
<UiIcon name="box" :size="16" />
ورود Excel
</UiButton>
<input
ref="fileInput"
type="file"
accept=".xlsx,.xls,.csv"
hidden
@change="onImportFile"
/>
<UiButton variant="primary" @click.stop="openCreate">
<UiIcon name="plus" :size="16" />
ثبت لید جدید
</UiButton>
</div>
</PhoneContactsImport>
<UiButton variant="primary" @click.stop="openCreate">
<UiIcon name="plus" :size="16" />
لید جدید
</UiButton>
</template>
</UiPageHeader>
@@ -697,7 +824,7 @@ onBeforeUnmount(stopImportPoll);
<button
class="sort-btn"
type="button"
@click.stop="sortOpen = !sortOpen; moreOpen = false"
@click.stop="sortOpen = !sortOpen"
>
<UiIcon name="trend" :size="15" />
{{ sortLabels[sort] }}
@@ -959,8 +1086,19 @@ onBeforeUnmount(stopImportPoll);
نام مخاطب
<input v-model="createForm.contactName" placeholder="مهدی احمدی" />
</label>
<label>
موبایل
<label class="mobile-field">
<span class="label-row">
موبایل
<button
type="button"
class="from-contact"
:disabled="pickingContact || createBusy"
@click="fillFromPhoneContact"
>
<UiIcon name="phone" :size="14" />
{{ pickingContact ? '…' : 'از مخاطبین' }}
</button>
</span>
<input
v-model="createForm.mobile"
required
@@ -969,6 +1107,13 @@ onBeforeUnmount(stopImportPoll);
inputmode="tel"
/>
</label>
<input
ref="contactFileInput"
class="hidden-file"
type="file"
accept=".vcf,text/vcard,text/x-vcard"
@change="onContactVcfFile"
/>
<div class="create-grid">
<label>
شهر
@@ -1000,6 +1145,57 @@ onBeforeUnmount(stopImportPoll);
</div>
<p v-if="createError" class="error">{{ createError }}</p>
</form>
<UiModal nest :open="sourceOpen" title="از مخاطبین" @close="closeSource">
<p class="pick-lead">
چند مخاطب را از دفترچه یا فایل بگیرید؛ بعد در لیست الفبایی جستجو کنید و یکی را برای پر کردن موبایل انتخاب کنید.
</p>
<div class="pick-choose">
<UiButton variant="primary" block :disabled="pickingContact" @click="pickFromDevice">
<UiIcon name="phone" :size="16" />
از دفترچه تلفن
</UiButton>
<UiButton variant="ghost" block :disabled="pickingContact" @click="pickFromVcf">
از فایل .vcf
</UiButton>
</div>
<template #footer>
<UiButton variant="ghost" @click="closeSource">انصراف</UiButton>
</template>
</UiModal>
<UiModal nest :open="pickOpen" title="انتخاب مخاطب" @close="closePick">
<p class="pick-lead">
{{ toFaDigits(pickDrafts.length) }} مخاطب (مرتب‌شده الفبایی).
یکی را انتخاب کنید تا موبایل (و در صورت خالی بودن، نام) پر شود.
</p>
<div class="pick-search">
<UiIcon name="search" :size="15" />
<input
v-model="pickQuery"
type="search"
placeholder="جستجو نام یا موبایل…"
autocomplete="off"
/>
</div>
<ul v-if="visiblePickDrafts.length" class="pick-list">
<li v-for="row in visiblePickDrafts" :key="row.key">
<button type="button" class="pick-row" @click="choosePick(row)">
<span class="pick-avatar" aria-hidden="true">{{ row.fullName.slice(0, 1) }}</span>
<span class="pick-meta">
<strong>{{ row.fullName }}</strong>
<small>{{ draftLine(row) }}</small>
</span>
</button>
</li>
</ul>
<p v-else class="pick-empty">مخاطبی با این جستجو پیدا نشد.</p>
<template #footer>
<UiButton variant="ghost" @click="closePick">انصراف</UiButton>
<UiButton variant="ghost" @click="contactFileInput?.click()">فایل دیگر</UiButton>
</template>
</UiModal>
<template #footer>
<UiButton variant="ghost" :disabled="createBusy" @click="createOpen = false">
انصراف
@@ -1023,29 +1219,8 @@ onBeforeUnmount(stopImportPoll);
box-sizing: border-box;
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
justify-content: flex-end;
}
.excel-btn {
gap: 0.35rem;
}
.more-btn {
width: var(--control-h, 2.5rem);
height: var(--control-h, 2.5rem);
border: 1px solid var(--line);
background: #fff;
border-radius: var(--radius);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--ink);
.btn-short {
display: none;
}
.stats {
@@ -1666,6 +1841,140 @@ onBeforeUnmount(stopImportPoll);
color: var(--ink);
}
.create-form .label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.from-contact {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
background: transparent;
color: var(--accent-deep, var(--accent));
font: inherit;
font-size: 0.75rem;
font-weight: 700;
cursor: pointer;
padding: 0;
line-height: 1;
}
.from-contact:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.hidden-file {
display: none;
}
.pick-lead {
margin: 0 0 0.75rem;
color: var(--muted);
font-size: 0.88rem;
line-height: 1.7;
}
.pick-choose {
display: grid;
gap: 0.55rem;
}
.pick-search {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.65rem;
padding: 0.45rem 0.65rem;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
}
.pick-search input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: 0.88rem;
color: var(--ink);
min-height: auto;
padding: 0;
}
.pick-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.45rem;
max-height: min(48vh, 360px);
overflow: auto;
}
.pick-row {
width: 100%;
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.55rem 0.65rem;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
cursor: pointer;
font: inherit;
text-align: start;
color: inherit;
}
.pick-row:hover {
border-color: var(--accent);
background: var(--accent-soft);
}
.pick-avatar {
width: 34px;
height: 34px;
border-radius: 10px;
background: var(--accent-soft);
color: var(--accent-deep, var(--accent));
display: grid;
place-items: center;
font-weight: 700;
flex-shrink: 0;
}
.pick-meta {
display: grid;
gap: 0.1rem;
min-width: 0;
}
.pick-meta strong {
font-size: 0.88rem;
}
.pick-meta small {
color: var(--muted);
font-size: 0.75rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pick-empty {
margin: 0.75rem 0 0;
color: var(--muted);
font-size: 0.88rem;
text-align: center;
}
.create-form input,
.create-form select {
min-height: 2.45rem;
@@ -1702,5 +2011,13 @@ onBeforeUnmount(stopImportPoll);
.search input {
width: 6rem;
}
:deep(.actions) .btn-full {
display: none;
}
:deep(.actions) .btn-short {
display: inline;
}
}
</style>