Files
CRM_Montzar/apps/web/src/components/PhoneContactsImport.vue
T
MahdiandCursor 4abab55a6b feat(web): migrate CRM screens to Ant Design Vue with central theme
Adopt ant-design-vue and a shared antdTheme for leads, pipeline, invoices, and shared form controls under strict single-page governance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 19:39:59 +03:30

378 lines
12 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type { CreatePersonRequest, PersonDto } from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
import {
Avatar as AAvatar,
Button as AButton,
Checkbox as ACheckbox,
Flex as AFlex,
Input as AInput,
List as AList,
ListItem as AListItem,
Modal as AModal,
Space as ASpace,
Typography,
Upload as AUpload,
} from 'ant-design-vue';
import type { UploadProps } from 'ant-design-vue';
import { PhoneOutlined, SearchOutlined, UserAddOutlined } from '@ant-design/icons-vue';
import {
filterPhoneContacts,
isContactPickerSupported,
parseVCardFile,
pickPhoneContacts,
sortPhoneContacts,
type PhoneContactDraft,
} from '@/lib/phone-contacts';
const { Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography;
const props = withDefaults(
defineProps<{
disabled?: boolean;
/** people = اشخاص | leads = لیدها */
target?: 'people' | 'leads';
}>(),
{ target: 'people' },
);
const emit = defineEmits<{
start: [];
done: [result: { created: number; skipped: number; failed: number }];
fail: [message: string];
settled: [];
}>();
const pickerSupported = isContactPickerSupported();
/** یک مودال با دو مرحله تا بعد از انتخاب فایل، لیست تأیید گم نشود */
const phase = ref<'closed' | 'choose' | 'help' | 'review'>('closed');
const importing = ref(false);
const drafts = ref<PhoneContactDraft[]>([]);
const selected = ref<Set<string>>(new Set());
const listQuery = ref('');
const modalOpen = computed(() => phase.value !== 'closed');
const modalTitle = computed(() => {
if (phase.value === 'review') return 'انتخاب مخاطبین برای ورود';
if (phase.value === 'choose') return 'ورود از مخاطبین گوشی';
return 'ورود از فایل مخاطبین';
});
const visibleDrafts = computed(() =>
filterPhoneContacts(drafts.value, listQuery.value),
);
const selectedCount = computed(
() => drafts.value.filter((d) => selected.value.has(d.key)).length,
);
function apiMessage(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
}
return fallback;
}
async function showReview(rows: PhoneContactDraft[], opts?: { selectAll?: boolean }) {
drafts.value = sortPhoneContacts(rows);
listQuery.value = '';
selected.value = opts?.selectAll ? new Set(drafts.value.map((r) => r.key)) : new Set();
phase.value = 'closed';
await nextTick();
phase.value = 'review';
}
function toggle(key: string) {
const next = new Set(selected.value);
if (next.has(key)) next.delete(key);
else next.add(key);
selected.value = next;
}
function selectAll(on: boolean) {
if (!on) {
selected.value = new Set();
return;
}
if (listQuery.value.trim()) {
const next = new Set(selected.value);
for (const row of visibleDrafts.value) next.add(row.key);
selected.value = next;
} else {
selected.value = new Set(drafts.value.map((d) => d.key));
}
}
function closeAll() {
phase.value = 'closed';
drafts.value = [];
selected.value = new Set();
listQuery.value = '';
importing.value = false;
}
function cancel() {
closeAll();
emit('settled');
}
function openHelp() {
phase.value = 'help';
}
function openChoose() {
phase.value = 'choose';
}
async function runDevicePicker() {
try {
const rows = await pickPhoneContacts({ multiple: true });
if (!rows.length) {
emit('fail', 'مخاطبی انتخاب نشد.');
return;
}
await showReview(rows, { selectAll: false });
} 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') {
openHelp();
return;
}
emit('fail', 'انتخاب مخاطب از گوشی ناموفق بود.');
}
}
function startPicker() {
if (pickerSupported) {
openChoose();
return;
}
openHelp();
}
const beforeVcfUpload: UploadProps['beforeUpload'] = (file) => {
void handleVcfFile(file as File);
return false;
};
async function handleVcfFile(file: File) {
try {
const rows = await parseVCardFile(file);
if (!rows.length) {
emit('fail', 'مخاطبی برای ورود پیدا نشد.');
phase.value = pickerSupported ? 'choose' : 'help';
return;
}
await showReview(rows, { selectAll: false });
} catch {
emit('fail', 'خواندن فایل مخاطبین (.vcf) ناموفق بود.');
phase.value = pickerSupported ? 'choose' : 'help';
}
}
function draftLine(row: PhoneContactDraft) {
return [row.mobile, row.phone, row.email].filter(Boolean).join(' · ') || 'بدون شماره/ایمیل';
}
async function importSelected() {
const rows = drafts.value.filter((d) => selected.value.has(d.key));
if (!rows.length || importing.value) return;
importing.value = true;
emit('start');
let created = 0;
let skipped = 0;
let failed = 0;
try {
for (const row of rows) {
try {
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,
importSource: 'phone',
};
await api.post<PersonDto>('/people', body);
}
created += 1;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 409) {
skipped += 1;
} else {
failed += 1;
if (failed === 1) {
emit('fail', apiMessage(e, `ورود «${row.fullName}» ناموفق بود`));
}
}
}
}
emit('done', { created, skipped, failed });
closeAll();
} finally {
importing.value = false;
emit('settled');
}
}
</script>
<template>
<span @click.stop>
<span @click="startPicker">
<slot />
</span>
<a-modal
:open="modalOpen"
:title="modalTitle"
:confirm-loading="importing"
@cancel="cancel"
>
<template v-if="phase === 'choose'">
<a-typography-paragraph type="secondary">
مخاطبین را از دفترچه تلفن یا فایل <strong>.vcf</strong> بگیرید.
بعد در لیست الفبایی جستجو کنید و یک، چند، یا همه را برای ورود انتخاب کنید.
</a-typography-paragraph>
<a-flex vertical :gap="8">
<a-button type="primary" block @click="runDevicePicker">
<template #icon><PhoneOutlined /></template>
از دفترچه تلفن
</a-button>
<a-upload
:show-upload-list="false"
accept=".vcf,text/vcard,text/x-vcard"
:before-upload="beforeVcfUpload"
>
<a-button block>از فایل .vcf</a-button>
</a-upload>
</a-flex>
</template>
<template v-else-if="phase === 'help'">
<a-typography-paragraph type="secondary">
در این مرورگر انتخاب مستقیم از دفترچه تلفن پشتیبانی نمی‌شود.
از Contacts گوشی یک فایل <strong>.vcf</strong> خروجی بگیرید و اینجا بارگذاری کنید.
بعد از بارگذاری می‌توانید یک، چند، یا همه مخاطبین را انتخاب کنید.
</a-typography-paragraph>
<a-typography-paragraph type="secondary">
اندروید Chrome معمولاً انتخاب مستقیم مخاطب را دارد. آیفون: Contacts → Share Contact /
Export → فایل vCard.
</a-typography-paragraph>
</template>
<template v-else-if="phase === 'review'">
<a-typography-paragraph type="secondary">
{{ toFaDigits(drafts.length) }} مخاطب از فایل/گوشی خوانده شد (مرتب‌شده الفبایی).
موارد دلخواه را تیک بزنید؛ فقط انتخاب‌شده‌ها وارد می‌شوند.
</a-typography-paragraph>
<a-input
v-model:value="listQuery"
allow-clear
placeholder="جستجو نام یا موبایل…"
style="width: 100%; margin-bottom: 12px"
>
<template #prefix><SearchOutlined /></template>
</a-input>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-space :size="8">
<a-button type="link" @click="selectAll(true)">
{{ listQuery.trim() ? 'انتخاب نتایج' : 'انتخاب همه' }}
</a-button>
<a-button type="link" @click="selectAll(false)">هیچ‌کدام</a-button>
</a-space>
<a-typography-text type="secondary">
{{ toFaDigits(selectedCount) }} انتخاب
<template v-if="listQuery.trim()">
· {{ toFaDigits(visibleDrafts.length) }} نتیجه
</template>
<template v-else>
از {{ toFaDigits(drafts.length) }}
</template>
</a-typography-text>
</a-flex>
<a-list
v-if="visibleDrafts.length"
:data-source="visibleDrafts"
:style="{ maxHeight: 'min(48vh, 360px)', overflow: 'auto' }"
>
<template #renderItem="{ item }">
<a-list-item>
<a-checkbox
:checked="selected.has(item.key)"
:disabled="importing"
@click.prevent="toggle(item.key)"
>
<a-space :size="8">
<a-avatar>{{ item.fullName.slice(0, 1) }}</a-avatar>
<span>
<div><a-typography-text strong>{{ item.fullName }}</a-typography-text></div>
<a-typography-text type="secondary">{{ draftLine(item) }}</a-typography-text>
</span>
</a-space>
</a-checkbox>
</a-list-item>
</template>
</a-list>
<a-typography-text v-else type="secondary">
مخاطبی با این جستجو پیدا نشد.
</a-typography-text>
</template>
<template #footer>
<template v-if="phase === 'choose'">
<a-button @click="cancel">انصراف</a-button>
</template>
<template v-else-if="phase === 'help'">
<a-button :disabled="importing" @click="cancel">انصراف</a-button>
<a-upload
:show-upload-list="false"
accept=".vcf,text/vcard,text/x-vcard"
:before-upload="beforeVcfUpload"
>
<a-button type="primary" :disabled="importing">انتخاب فایل .vcf</a-button>
</a-upload>
</template>
<template v-else-if="phase === 'review'">
<a-button :disabled="importing" @click="cancel">انصراف</a-button>
<a-upload
:show-upload-list="false"
accept=".vcf,text/vcard,text/x-vcard"
:before-upload="beforeVcfUpload"
:disabled="importing"
>
<a-button :disabled="importing">فایل دیگر</a-button>
</a-upload>
<a-button
type="primary"
:disabled="importing || !selectedCount"
:loading="importing"
@click="importSelected"
>
<template #icon><UserAddOutlined /></template>
ورود {{ toFaDigits(selectedCount) }}
{{ target === 'leads' ? 'لید' : 'نفر' }}
</a-button>
</template>
</template>
</a-modal>
</span>
</template>