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

486 lines
13 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 { UiButton, UiModal } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import {
filterPhoneContacts,
isContactPickerSupported,
parseVCardFile,
pickPhoneContacts,
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();
/** یک مودال با دو مرحله تا بعد از انتخاب فایل، لیست تأیید گم نشود */
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 fileInput = ref<HTMLInputElement | null>(null);
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;
}
// مثل vcf: جستجو/انتخاب دستی، نه ورود خودکار همه
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();
}
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);
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 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"
/>
<UiModal :open="modalOpen" :title="modalTitle" @close="cancel">
<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'">
<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">
{{ toFaDigits(drafts.length) }} مخاطب از فایل/گوشی خوانده شد (مرتب‌شده الفبایی).
موارد دلخواه را تیک بزنید؛ فقط انتخاب‌شده‌ها وارد می‌شوند.
</p>
<div class="search">
<UiIcon name="search" :size="15" />
<input
v-model="listQuery"
type="search"
placeholder="جستجو نام یا موبایل…"
autocomplete="off"
/>
</div>
<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">
<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>
<p v-else class="empty">مخاطبی با این جستجو پیدا نشد.</p>
</template>
<template #footer>
<template v-if="phase === 'choose'">
<UiButton variant="ghost" @click="cancel">انصراف</UiButton>
</template>
<template v-else-if="phase === 'help'">
<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' ? 'لید' : 'نفر' }}
</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;
}
.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;
}
.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;
}
.empty {
margin: 0.75rem 0 0;
color: #94a3b8;
font-size: 0.84rem;
text-align: center;
}
</style>