Edit: Add from phone contacts

This commit is contained in:
2026-09-21 20:04:18 +03:30
parent f4cb41d96f
commit 5d9a572ff8
3 changed files with 237 additions and 89 deletions
+104 -87
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, nextTick, ref } from 'vue';
import axios from 'axios'; import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { CreatePersonRequest, PersonDto } from '@mento/shared'; import type { CreatePersonRequest, PersonDto } from '@mento/shared';
@@ -25,16 +25,23 @@ const emit = defineEmits<{
}>(); }>();
const pickerSupported = isContactPickerSupported(); const pickerSupported = isContactPickerSupported();
const open = ref(false); /** یک مودال با دو مرحله تا بعد از انتخاب فایل، لیست تأیید گم نشود */
const fallbackOpen = ref(false); const phase = ref<'closed' | 'help' | 'review'>('closed');
const importing = ref(false); const importing = ref(false);
const drafts = ref<PhoneContactDraft[]>([]); const drafts = ref<PhoneContactDraft[]>([]);
const selected = ref<Set<string>>(new Set()); const selected = ref<Set<string>>(new Set());
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
const modalOpen = computed(() => phase.value !== 'closed');
const modalTitle = computed(() =>
phase.value === 'review' ? 'انتخاب مخاطبین برای ورود' : 'ورود از مخاطبین گوشی',
);
const selectedCount = computed( const selectedCount = computed(
() => drafts.value.filter((d) => selected.value.has(d.key)).length, () => drafts.value.filter((d) => selected.value.has(d.key)).length,
); );
const allSelected = computed(
() => drafts.value.length > 0 && selectedCount.value === drafts.value.length,
);
function apiMessage(e: unknown, fallback: string) { function apiMessage(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) { if (axios.isAxiosError(e)) {
@@ -44,15 +51,13 @@ function apiMessage(e: unknown, fallback: string) {
return fallback; return fallback;
} }
function setDrafts(rows: PhoneContactDraft[]) { async function showReview(rows: PhoneContactDraft[], opts?: { selectAll?: boolean }) {
drafts.value = rows; drafts.value = rows;
selected.value = new Set(rows.map((r) => r.key)); selected.value = opts?.selectAll ? new Set(rows.map((r) => r.key)) : new Set();
open.value = rows.length > 0; // بعد از دیالوگ فایل، یک تیک صبر کن تا مودال مطمئن باز شود
fallbackOpen.value = false; phase.value = 'closed';
if (!rows.length) { await nextTick();
emit('fail', 'مخاطبی برای ورود پیدا نشد.'); phase.value = 'review';
emit('settled');
}
} }
function toggle(key: string) { function toggle(key: string) {
@@ -67,8 +72,7 @@ function selectAll(on: boolean) {
} }
function closeAll() { function closeAll() {
open.value = false; phase.value = 'closed';
fallbackOpen.value = false;
drafts.value = []; drafts.value = [];
selected.value = new Set(); selected.value = new Set();
importing.value = false; importing.value = false;
@@ -79,32 +83,32 @@ function cancel() {
emit('settled'); emit('settled');
} }
function openFallback() { function openHelp() {
fallbackOpen.value = true; phase.value = 'help';
} }
async function startPicker() { async function startPicker() {
emit('start');
try { try {
if (!pickerSupported) { if (!pickerSupported) {
openFallback(); openHelp();
return; return;
} }
const rows = await pickPhoneContacts(); const rows = await pickPhoneContacts({ multiple: true });
setDrafts(rows); if (!rows.length) {
emit('fail', 'مخاطبی انتخاب نشد.');
return;
}
// از Contact Picker قبلاً انتخاب شده‌اند → همه تیک‌خورده، ولی هنوز قابل کم/زیاد کردن
await showReview(rows, { selectAll: true });
} catch (e) { } catch (e) {
const name = e instanceof DOMException ? e.name : ''; const name = e instanceof DOMException ? e.name : '';
const message = e instanceof Error ? e.message : ''; const message = e instanceof Error ? e.message : '';
if (name === 'AbortError') { if (name === 'AbortError') return;
emit('settled');
return;
}
if (message === 'CONTACT_PICKER_UNSUPPORTED' || name === 'NotSupportedError') { if (message === 'CONTACT_PICKER_UNSUPPORTED' || name === 'NotSupportedError') {
openFallback(); openHelp();
return; return;
} }
emit('fail', 'انتخاب مخاطب از گوشی ناموفق بود.'); emit('fail', 'انتخاب مخاطب از گوشی ناموفق بود.');
emit('settled');
} }
} }
@@ -115,11 +119,16 @@ async function onVcfFile(ev: Event) {
if (!file) return; if (!file) return;
try { try {
const rows = await parseVCardFile(file); const rows = await parseVCardFile(file);
setDrafts(rows); if (!rows.length) {
emit('fail', 'مخاطبی برای ورود پیدا نشد.');
phase.value = 'help';
return;
}
// از فایل: هیچ‌کدام پیش‌فرض انتخاب نشود تا کاربر خودش یک/چند/همه را بزند
await showReview(rows, { selectAll: false });
} catch { } catch {
emit('fail', 'خواندن فایل مخاطبین (.vcf) ناموفق بود.'); emit('fail', 'خواندن فایل مخاطبین (.vcf) ناموفق بود.');
emit('settled'); phase.value = 'help';
fallbackOpen.value = false;
} }
} }
@@ -131,6 +140,7 @@ async function importSelected() {
const rows = drafts.value.filter((d) => selected.value.has(d.key)); const rows = drafts.value.filter((d) => selected.value.has(d.key));
if (!rows.length || importing.value) return; if (!rows.length || importing.value) return;
importing.value = true; importing.value = true;
emit('start');
let created = 0; let created = 0;
let skipped = 0; let skipped = 0;
let failed = 0; let failed = 0;
@@ -180,68 +190,75 @@ async function importSelected() {
@change="onVcfFile" @change="onVcfFile"
/> />
<UiModal :open="fallbackOpen" title="ورود از مخاطبین گوشی" @close="cancel"> <UiModal :open="modalOpen" :title="modalTitle" @close="cancel">
<p class="lead"> <template v-if="phase === 'help'">
در این مرورگر انتخاب مستقیم از دفترچه تلفن پشتیبانی نمی‌شود. <p class="lead">
از Contacts گوشی یک فایل <strong>.vcf</strong> خروجی بگیرید و اینجا بارگذاری کنید. در این مرورگر انتخاب مستقیم از دفترچه تلفن پشتیبانی نمی‌شود.
</p> از Contacts گوشی یک فایل <strong>.vcf</strong> خروجی بگیرید و اینجا بارگذاری کنید.
<ul class="tips"> بعد از بارگذاری می‌توانید یک، چند، یا همه مخاطبین را انتخاب کنید.
<li>اندروید Chrome معمولاً انتخاب مستقیم مخاطب را دارد.</li> </p>
<li>آیفون: Contacts → Share Contact / Export → فایل vCard.</li> <ul class="tips">
</ul> <li>اندروید Chrome معمولاً انتخاب مستقیم مخاطب را دارد.</li>
<template #footer> <li>آیفون: Contacts → Share Contact / Export → فایل vCard.</li>
<UiButton variant="ghost" :disabled="importing" @click="cancel">انصراف</UiButton> </ul>
<UiButton variant="primary" :disabled="importing || disabled" @click="fileInput?.click()"> </template>
انتخاب فایل .vcf
</UiButton> <template v-else-if="phase === 'review'">
<p class="lead">
{{ toFaDigits(drafts.length) }} مخاطب از فایل/گوشی خوانده شد.
موارد دلخواه را تیک بزنید؛ فقط انتخاب‌شده‌ها وارد می‌شوند.
</p>
<div class="toolbar">
<button type="button" class="link" @click="selectAll(true)">انتخاب همه</button>
<button type="button" class="link" @click="selectAll(false)">هیچ‌کدام</button>
<span class="count">{{ toFaDigits(selectedCount) }} از {{ toFaDigits(drafts.length) }}</span>
</div>
<ul class="list">
<li v-for="row in drafts" :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>
</template> </template>
</UiModal>
<UiModal :open="open" title="تأیید مخاطبین" @close="cancel">
<p class="lead">
{{ toFaDigits(drafts.length) }} مخاطب آماده ورود است. موارد دلخواه را انتخاب کنید.
</p>
<div class="toolbar">
<button type="button" class="link" @click="selectAll(true)">انتخاب همه</button>
<button type="button" class="link" @click="selectAll(false)">هیچ‌کدام</button>
<span class="count">{{ toFaDigits(selectedCount) }} انتخاب‌شده</span>
</div>
<ul class="list">
<li v-for="row in drafts" :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>
<template #footer> <template #footer>
<UiButton variant="ghost" :disabled="importing" @click="cancel">انصراف</UiButton> <template v-if="phase === 'help'">
<UiButton <UiButton variant="ghost" :disabled="importing" @click="cancel">انصراف</UiButton>
v-if="!pickerSupported" <UiButton variant="primary" :disabled="importing" @click="fileInput?.click()">
variant="ghost" انتخاب فایل .vcf
:disabled="importing" </UiButton>
@click="fileInput?.click()" </template>
> <template v-else-if="phase === 'review'">
فایل دیگر <UiButton variant="ghost" :disabled="importing" @click="cancel">انصراف</UiButton>
</UiButton> <UiButton variant="ghost" :disabled="importing" @click="fileInput?.click()">
<UiButton فایل دیگر
variant="primary" </UiButton>
:disabled="importing || !selectedCount" <UiButton
:loading="importing" variant="primary"
@click="importSelected" :disabled="importing || !selectedCount"
> :loading="importing"
<UiIcon name="user-plus" :size="16" /> @click="importSelected"
ورود {{ toFaDigits(selectedCount) }} نفر >
</UiButton> <UiIcon name="user-plus" :size="16" />
<template v-if="allSelected && selectedCount">
ورود همه ({{ toFaDigits(selectedCount) }})
</template>
<template v-else>
ورود {{ toFaDigits(selectedCount) }} نفر
</template>
</UiButton>
</template>
</template> </template>
</UiModal> </UiModal>
</span> </span>
+11 -2
View File
@@ -99,12 +99,15 @@ function draftFromParts(input: {
}; };
} }
export async function pickPhoneContacts(): Promise<PhoneContactDraft[]> { export async function pickPhoneContacts(options?: {
multiple?: boolean;
}): Promise<PhoneContactDraft[]> {
const api = contactsApi(); const api = contactsApi();
if (!api) { if (!api) {
throw new Error('CONTACT_PICKER_UNSUPPORTED'); throw new Error('CONTACT_PICKER_UNSUPPORTED');
} }
const rows = await api.select(['name', 'email', 'tel'], { multiple: true }); const multiple = options?.multiple !== false;
const rows = await api.select(['name', 'email', 'tel'], { multiple });
const drafts: PhoneContactDraft[] = []; const drafts: PhoneContactDraft[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const row of rows) { for (const row of rows) {
@@ -121,6 +124,12 @@ export async function pickPhoneContacts(): Promise<PhoneContactDraft[]> {
return drafts; return drafts;
} }
/** یک مخاطب برای پر کردن فرم (نه ورود گروهی) */
export async function pickOnePhoneContact(): Promise<PhoneContactDraft | null> {
const rows = await pickPhoneContacts({ multiple: false });
return rows[0] ?? null;
}
function unfoldVcard(text: string): string[] { function unfoldVcard(text: string): string[] {
const raw = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); const raw = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
const lines: string[] = []; const lines: string[] = [];
+122
View File
@@ -5,6 +5,12 @@ import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { CompanyDto, CreatePersonRequest, PersonDto, PersonMood } from '@mento/shared'; import type { CompanyDto, CreatePersonRequest, PersonDto, PersonMood } from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood'; import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
import {
isContactPickerSupported,
parseVCardFile,
pickOnePhoneContact,
type PhoneContactDraft,
} from '@/lib/phone-contacts';
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'; import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import CustomerMoodFace from '@/components/CustomerMoodFace.vue'; import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
import EntityTimeline from '@/components/EntityTimeline.vue'; import EntityTimeline from '@/components/EntityTimeline.vue';
@@ -56,10 +62,83 @@ const customFields = ref<Record<string, unknown>>({});
const loyaltyPoints = ref(0); const loyaltyPoints = ref(0);
const loyaltyTierName = ref(''); const loyaltyTierName = ref('');
const contactPickerSupported = isContactPickerSupported();
const pickingContact = ref(false);
const contactFileInput = ref<HTMLInputElement | null>(null);
const backTo = computed(() => const backTo = computed(() =>
isNew.value ? { name: 'contacts' as const } : { name: 'person-detail' as const, params: { id: id.value } }, isNew.value ? { name: 'contacts' as const } : { name: 'person-detail' as const, params: { id: id.value } },
); );
/** فقط موبایل را اجباری پر می‌کند؛ نام و بقیه فقط اگر خالی باشند. */
function applyContactDraft(draft: PhoneContactDraft) {
if (draft.mobile) {
mobile.value = draft.mobile;
} else if (draft.phone && !mobile.value.trim()) {
mobile.value = draft.phone;
}
if (!firstName.value.trim() && draft.firstName && draft.firstName !== 'بدون‌نام') {
firstName.value = draft.firstName;
}
if (!lastName.value.trim() && draft.lastName) {
lastName.value = draft.lastName;
}
if (!phone.value.trim() && draft.phone && draft.phone !== draft.mobile) {
phone.value = draft.phone;
}
if (!email.value.trim() && draft.email) {
email.value = draft.email;
}
}
async function fillFromPhoneContact() {
if (pickingContact.value || saving.value) return;
pickingContact.value = true;
error.value = '';
try {
if (!contactPickerSupported) {
contactFileInput.value?.click();
return;
}
const draft = await pickOnePhoneContact();
if (!draft) return;
applyContactDraft(draft);
} 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;
}
error.value = 'انتخاب مخاطب از گوشی ناموفق بود.';
} finally {
pickingContact.value = false;
}
}
async function onContactVcfFile(ev: Event) {
const el = ev.target as HTMLInputElement;
const file = el.files?.[0];
el.value = '';
if (!file) return;
pickingContact.value = true;
error.value = '';
try {
const rows = await parseVCardFile(file);
const draft = rows[0];
if (!draft) {
error.value = 'مخاطبی در فایل پیدا نشد.';
return;
}
applyContactDraft(draft);
} catch {
error.value = 'خواندن فایل مخاطب (.vcf) ناموفق بود.';
} finally {
pickingContact.value = false;
}
}
function fill(p: PersonDto) { function fill(p: PersonDto) {
firstName.value = p.firstName; firstName.value = p.firstName;
lastName.value = p.lastName; lastName.value = p.lastName;
@@ -205,9 +284,28 @@ onMounted(load);
</UiField> </UiField>
<UiField label="موبایل" for-id="mobile"> <UiField label="موبایل" for-id="mobile">
<template #label-extra>
<button
type="button"
class="from-contact"
:disabled="pickingContact || saving"
@click="fillFromPhoneContact"
>
<UiIcon name="phone" :size="14" />
{{ pickingContact ? '…' : 'از مخاطبین' }}
</button>
</template>
<UiInput id="mobile" v-model="mobile" type="tel" /> <UiInput id="mobile" v-model="mobile" type="tel" />
</UiField> </UiField>
<input
ref="contactFileInput"
class="hidden-file"
type="file"
accept=".vcf,text/vcard,text/x-vcard"
@change="onContactVcfFile"
/>
<UiField label="تلفن" for-id="phone"> <UiField label="تلفن" for-id="phone">
<UiInput id="phone" v-model="phone" type="tel" /> <UiInput id="phone" v-model="phone" type="tel" />
</UiField> </UiField>
@@ -367,6 +465,30 @@ onMounted(load);
margin-top: var(--sp-4); margin-top: var(--sp-4);
} }
.from-contact {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
background: transparent;
color: var(--accent-deep);
font: inherit;
font-size: var(--fs-xs);
font-weight: var(--fw-bold);
cursor: pointer;
padding: 0;
line-height: 1;
}
.from-contact:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.hidden-file {
display: none;
}
@media (min-width: 901px) { @media (min-width: 901px) {
.mob-only { .mob-only {
display: none !important; display: none !important;