Files
CRM_Montzar/apps/web/src/components/PhoneContactsImport.vue
T

486 lines
13 KiB
Vue
Raw Normal View History

<script setup lang="ts">
2026-09-21 20:04:18 +03:30
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 { UiButton, UiModal } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import {
2026-09-21 20:26:19 +03:30
filterPhoneContacts,
isContactPickerSupported,
parseVCardFile,
pickPhoneContacts,
2026-09-21 20:26:19 +03:30
sortPhoneContacts,
type PhoneContactDraft,
} from '@/lib/phone-contacts';
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();
2026-09-21 20:04:18 +03:30
/** یک مودال با دو مرحله تا بعد از انتخاب فایل، لیست تأیید گم نشود */
2026-09-21 20:51:06 +03:30
const phase = ref<'closed' | 'choose' | 'help' | 'review'>('closed');
const importing = ref(false);
const drafts = ref<PhoneContactDraft[]>([]);
const selected = ref<Set<string>>(new Set());
2026-09-21 20:26:19 +03:30
const listQuery = ref('');
const fileInput = ref<HTMLInputElement | null>(null);
2026-09-21 20:04:18 +03:30
const modalOpen = computed(() => phase.value !== 'closed');
2026-09-21 20:51:06 +03:30
const modalTitle = computed(() => {
if (phase.value === 'review') return 'انتخاب مخاطبین برای ورود';
if (phase.value === 'choose') return 'ورود از مخاطبین گوشی';
return 'ورود از فایل مخاطبین';
});
2026-09-21 20:26:19 +03:30
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;
}
2026-09-21 20:04:18 +03:30
async function showReview(rows: PhoneContactDraft[], opts?: { selectAll?: boolean }) {
2026-09-21 20:26:19 +03:30
drafts.value = sortPhoneContacts(rows);
listQuery.value = '';
selected.value = opts?.selectAll ? new Set(drafts.value.map((r) => r.key)) : new Set();
2026-09-21 20:04:18 +03:30
// بعد از دیالوگ فایل، یک تیک صبر کن تا مودال مطمئن باز شود
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) {
2026-09-21 20:26:19 +03:30
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() {
2026-09-21 20:04:18 +03:30
phase.value = 'closed';
drafts.value = [];
selected.value = new Set();
2026-09-21 20:26:19 +03:30
listQuery.value = '';
importing.value = false;
}
function cancel() {
closeAll();
emit('settled');
}
2026-09-21 20:04:18 +03:30
function openHelp() {
phase.value = 'help';
}
2026-09-21 20:51:06 +03:30
function openChoose() {
phase.value = 'choose';
}
async function runDevicePicker() {
try {
2026-09-21 20:04:18 +03:30
const rows = await pickPhoneContacts({ multiple: true });
if (!rows.length) {
emit('fail', 'مخاطبی انتخاب نشد.');
return;
}
2026-09-21 20:51:06 +03:30
// مثل vcf: جستجو/انتخاب دستی، نه ورود خودکار همه
await showReview(rows, { selectAll: false });
} catch (e) {
const name = e instanceof DOMException ? e.name : '';
const message = e instanceof Error ? e.message : '';
2026-09-21 20:04:18 +03:30
if (name === 'AbortError') return;
if (message === 'CONTACT_PICKER_UNSUPPORTED' || name === 'NotSupportedError') {
2026-09-21 20:04:18 +03:30
openHelp();
return;
}
emit('fail', 'انتخاب مخاطب از گوشی ناموفق بود.');
}
}
2026-09-21 20:51:06 +03:30
function startPicker() {
if (pickerSupported) {
openChoose();
return;
}
openHelp();
}
async function onVcfFile(ev: Event) {
const el = ev.target as HTMLInputElement;
const file = el.files?.[0];
el.value = '';
if (!file) return;
try {
const rows = await parseVCardFile(file);
2026-09-21 20:04:18 +03:30
if (!rows.length) {
emit('fail', 'مخاطبی برای ورود پیدا نشد.');
2026-09-21 20:51:06 +03:30
phase.value = pickerSupported ? 'choose' : 'help';
2026-09-21 20:04:18 +03:30
return;
}
await showReview(rows, { selectAll: false });
} catch {
emit('fail', 'خواندن فایل مخاطبین (.vcf) ناموفق بود.');
2026-09-21 20:51:06 +03:30
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;
2026-09-21 20:04:18 +03:30
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 class="wrap" @click.stop>
<span class="trigger" @click="startPicker">
<slot />
</span>
<input
ref="fileInput"
class="hidden"
type="file"
accept=".vcf,text/vcard,text/x-vcard"
@change="onVcfFile"
/>
2026-09-21 20:04:18 +03:30
<UiModal :open="modalOpen" :title="modalTitle" @close="cancel">
2026-09-21 20:51:06 +03:30
<template v-if="phase === 'choose'">
<p class="lead">
مخاطبین را از دفترچه تلفن یا فایل <strong>.vcf</strong> بگیرید.
بعد در لیست الفبایی جستجو کنید و یک، چند، یا همه را برای ورود انتخاب کنید.
</p>
<div class="choose-actions">
<UiButton variant="primary" block @click="runDevicePicker">
<UiIcon name="phone" :size="16" />
از دفترچه تلفن
</UiButton>
<UiButton variant="ghost" block @click="fileInput?.click()">
از فایل .vcf
</UiButton>
</div>
</template>
<template v-else-if="phase === 'help'">
2026-09-21 20:04:18 +03:30
<p class="lead">
در این مرورگر انتخاب مستقیم از دفترچه تلفن پشتیبانی نمی‌شود.
از Contacts گوشی یک فایل <strong>.vcf</strong> خروجی بگیرید و اینجا بارگذاری کنید.
بعد از بارگذاری می‌توانید یک، چند، یا همه مخاطبین را انتخاب کنید.
</p>
<ul class="tips">
<li>اندروید Chrome معمولاً انتخاب مستقیم مخاطب را دارد.</li>
<li>آیفون: Contacts → Share Contact / Export → فایل vCard.</li>
</ul>
</template>
<template v-else-if="phase === 'review'">
<p class="lead">
2026-09-21 20:26:19 +03:30
{{ toFaDigits(drafts.length) }} مخاطب از فایل/گوشی خوانده شد (مرتب‌شده الفبایی).
2026-09-21 20:04:18 +03:30
موارد دلخواه را تیک بزنید؛ فقط انتخاب‌شده‌ها وارد می‌شوند.
</p>
2026-09-21 20:26:19 +03:30
<div class="search">
<UiIcon name="search" :size="15" />
<input
v-model="listQuery"
type="search"
placeholder="جستجو نام یا موبایل…"
autocomplete="off"
/>
2026-09-21 20:04:18 +03:30
</div>
2026-09-21 20:26:19 +03:30
<div class="toolbar">
<button type="button" class="link" @click="selectAll(true)">
{{ listQuery.trim() ? 'انتخاب نتایج' : 'انتخاب همه' }}
</button>
<button type="button" class="link" @click="selectAll(false)">هیچ‌کدام</button>
<span class="count">
{{ toFaDigits(selectedCount) }} انتخاب
<template v-if="listQuery.trim()">
· {{ toFaDigits(visibleDrafts.length) }} نتیجه
</template>
<template v-else>
از {{ toFaDigits(drafts.length) }}
</template>
</span>
</div>
<ul v-if="visibleDrafts.length" class="list">
<li v-for="row in visibleDrafts" :key="row.key">
2026-09-21 20:04:18 +03:30
<label>
<input
type="checkbox"
:checked="selected.has(row.key)"
:disabled="importing"
@change="toggle(row.key)"
/>
<span class="avatar" aria-hidden="true">{{ row.fullName.slice(0, 1) }}</span>
<span class="meta">
<strong>{{ row.fullName }}</strong>
<small>{{ draftLine(row) }}</small>
</span>
</label>
</li>
</ul>
2026-09-21 20:26:19 +03:30
<p v-else class="empty">مخاطبی با این جستجو پیدا نشد.</p>
</template>
<template #footer>
2026-09-21 20:51:06 +03:30
<template v-if="phase === 'choose'">
<UiButton variant="ghost" @click="cancel">انصراف</UiButton>
</template>
<template v-else-if="phase === 'help'">
2026-09-21 20:04:18 +03:30
<UiButton variant="ghost" :disabled="importing" @click="cancel">انصراف</UiButton>
<UiButton variant="primary" :disabled="importing" @click="fileInput?.click()">
انتخاب فایل .vcf
</UiButton>
</template>
<template v-else-if="phase === 'review'">
<UiButton variant="ghost" :disabled="importing" @click="cancel">انصراف</UiButton>
<UiButton variant="ghost" :disabled="importing" @click="fileInput?.click()">
فایل دیگر
</UiButton>
<UiButton
variant="primary"
:disabled="importing || !selectedCount"
:loading="importing"
@click="importSelected"
>
<UiIcon name="user-plus" :size="16" />
ورود {{ toFaDigits(selectedCount) }}
{{ target === 'leads' ? 'لید' : 'نفر' }}
2026-09-21 20:04:18 +03:30
</UiButton>
</template>
</template>
</UiModal>
</span>
</template>
<style scoped>
.wrap {
display: inline-flex;
}
.trigger {
display: inline-flex;
}
.hidden {
display: none;
}
.lead {
margin: 0 0 0.85rem;
color: #64748b;
font-size: 0.88rem;
line-height: 1.7;
}
2026-09-21 20:51:06 +03:30
.choose-actions {
display: grid;
gap: 0.55rem;
}
.tips {
margin: 0;
padding: 0 1.1rem 0 0;
color: #475569;
font-size: 0.84rem;
line-height: 1.8;
}
2026-09-21 20:26:19 +03:30
.search {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.65rem;
padding: 0.45rem 0.65rem;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
background: #fff;
}
.search input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: 0.84rem;
color: #0f172a;
}
.toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.7rem;
flex-wrap: wrap;
}
.link {
border: 0;
background: transparent;
color: #0f766e;
font: inherit;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
padding: 0;
}
.count {
margin-inline-start: auto;
color: #94a3b8;
font-size: 0.78rem;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.45rem;
max-height: min(48vh, 360px);
overflow: auto;
}
.list label {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.55rem 0.65rem;
border: 1px solid #eef2f7;
border-radius: var(--radius);
background: #fff;
cursor: pointer;
}
.avatar {
width: 34px;
height: 34px;
border-radius: var(--radius);
background: #ecfdf8;
color: #0f766e;
display: grid;
place-items: center;
font-weight: 700;
flex-shrink: 0;
}
.meta {
display: grid;
gap: 0.1rem;
min-width: 0;
}
.meta strong {
font-size: 0.86rem;
}
.meta small {
color: #94a3b8;
font-size: 0.74rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
2026-09-21 20:26:19 +03:30
.empty {
margin: 0.75rem 0 0;
color: #94a3b8;
font-size: 0.84rem;
text-align: center;
}
</style>