354 lines
8.6 KiB
Vue
354 lines
8.6 KiB
Vue
<script setup lang="ts">
|
||||
|
|
import { computed, 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 {
|
|||
|
|
isContactPickerSupported,
|
|||
|
|
parseVCardFile,
|
|||
|
|
pickPhoneContacts,
|
|||
|
|
type PhoneContactDraft,
|
|||
|
|
} from '@/lib/phone-contacts';
|
|||
|
|
|
|||
|
|
defineProps<{
|
|||
|
|
disabled?: boolean;
|
|||
|
|
}>();
|
|||
|
|
|
|||
|
|
const emit = defineEmits<{
|
|||
|
|
start: [];
|
|||
|
|
done: [result: { created: number; skipped: number; failed: number }];
|
|||
|
|
fail: [message: string];
|
|||
|
|
settled: [];
|
|||
|
|
}>();
|
|||
|
|
|
|||
|
|
const pickerSupported = isContactPickerSupported();
|
|||
|
|
const open = ref(false);
|
|||
|
|
const fallbackOpen = ref(false);
|
|||
|
|
const importing = ref(false);
|
|||
|
|
const drafts = ref<PhoneContactDraft[]>([]);
|
|||
|
|
const selected = ref<Set<string>>(new Set());
|
|||
|
|
const fileInput = ref<HTMLInputElement | null>(null);
|
|||
|
|
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setDrafts(rows: PhoneContactDraft[]) {
|
|||
|
|
drafts.value = rows;
|
|||
|
|
selected.value = new Set(rows.map((r) => r.key));
|
|||
|
|
open.value = rows.length > 0;
|
|||
|
|
fallbackOpen.value = false;
|
|||
|
|
if (!rows.length) {
|
|||
|
|
emit('fail', 'مخاطبی برای ورود پیدا نشد.');
|
|||
|
|
emit('settled');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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) {
|
|||
|
|
selected.value = on ? new Set(drafts.value.map((d) => d.key)) : new Set();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function closeAll() {
|
|||
|
|
open.value = false;
|
|||
|
|
fallbackOpen.value = false;
|
|||
|
|
drafts.value = [];
|
|||
|
|
selected.value = new Set();
|
|||
|
|
importing.value = false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function cancel() {
|
|||
|
|
closeAll();
|
|||
|
|
emit('settled');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openFallback() {
|
|||
|
|
fallbackOpen.value = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function startPicker() {
|
|||
|
|
emit('start');
|
|||
|
|
try {
|
|||
|
|
if (!pickerSupported) {
|
|||
|
|
openFallback();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const rows = await pickPhoneContacts();
|
|||
|
|
setDrafts(rows);
|
|||
|
|
} catch (e) {
|
|||
|
|
const name = e instanceof DOMException ? e.name : '';
|
|||
|
|
const message = e instanceof Error ? e.message : '';
|
|||
|
|
if (name === 'AbortError') {
|
|||
|
|
emit('settled');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (message === 'CONTACT_PICKER_UNSUPPORTED' || name === 'NotSupportedError') {
|
|||
|
|
openFallback();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
emit('fail', 'انتخاب مخاطب از گوشی ناموفق بود.');
|
|||
|
|
emit('settled');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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);
|
|||
|
|
setDrafts(rows);
|
|||
|
|
} catch {
|
|||
|
|
emit('fail', 'خواندن فایل مخاطبین (.vcf) ناموفق بود.');
|
|||
|
|
emit('settled');
|
|||
|
|
fallbackOpen.value = false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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;
|
|||
|
|
let created = 0;
|
|||
|
|
let skipped = 0;
|
|||
|
|
let failed = 0;
|
|||
|
|
try {
|
|||
|
|
for (const row of rows) {
|
|||
|
|
const body: CreatePersonRequest = {
|
|||
|
|
firstName: row.firstName,
|
|||
|
|
lastName: row.lastName || undefined,
|
|||
|
|
mobile: row.mobile,
|
|||
|
|
phone: row.phone,
|
|||
|
|
email: row.email,
|
|||
|
|
};
|
|||
|
|
try {
|
|||
|
|
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="fallbackOpen" title="ورود از مخاطبین گوشی" @close="cancel">
|
|||
|
|
<p class="lead">
|
|||
|
|
در این مرورگر انتخاب مستقیم از دفترچه تلفن پشتیبانی نمیشود.
|
|||
|
|
از Contacts گوشی یک فایل <strong>.vcf</strong> خروجی بگیرید و اینجا بارگذاری کنید.
|
|||
|
|
</p>
|
|||
|
|
<ul class="tips">
|
|||
|
|
<li>اندروید Chrome معمولاً انتخاب مستقیم مخاطب را دارد.</li>
|
|||
|
|
<li>آیفون: Contacts → Share Contact / Export → فایل vCard.</li>
|
|||
|
|
</ul>
|
|||
|
|
<template #footer>
|
|||
|
|
<UiButton variant="ghost" :disabled="importing" @click="cancel">انصراف</UiButton>
|
|||
|
|
<UiButton variant="primary" :disabled="importing || disabled" @click="fileInput?.click()">
|
|||
|
|
انتخاب فایل .vcf
|
|||
|
|
</UiButton>
|
|||
|
|
</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>
|
|||
|
|
<UiButton variant="ghost" :disabled="importing" @click="cancel">انصراف</UiButton>
|
|||
|
|
<UiButton
|
|||
|
|
v-if="!pickerSupported"
|
|||
|
|
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) }} نفر
|
|||
|
|
</UiButton>
|
|||
|
|
</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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tips {
|
|||
|
|
margin: 0;
|
|||
|
|
padding: 0 1.1rem 0 0;
|
|||
|
|
color: #475569;
|
|||
|
|
font-size: 0.84rem;
|
|||
|
|
line-height: 1.8;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.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;
|
|||
|
|
}
|
|||
|
|
</style>
|