Files
CRM_Montzar/apps/web/src/components/PhoneContactsImport.vue
T
MahdiandCursor 6c6e32e099 feat(web): adopt vuesax icons and shared skeleton loading
Replace the inline SVG icon set with the vuesax linear pack, add skeleton primitives for loading states, and keep page scroll inside the app shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 14:46:35 +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 '@/icons/ant-bridge';
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>