Male and female names can store the title when requested, and Tahesab import and export use the same gender. Co-authored-by: Cursor <cursoragent@cursor.com>
762 lines
25 KiB
Vue
762 lines
25 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||
import axios from 'axios';
|
||
import {
|
||
Alert as AAlert,
|
||
Button as AButton,
|
||
Card as ACard,
|
||
Checkbox as ACheckbox,
|
||
Col as ACol,
|
||
Divider as ADivider,
|
||
Flex as AFlex,
|
||
Form as AForm,
|
||
FormItem as AFormItem,
|
||
Input as AInput,
|
||
List as AList,
|
||
Modal,
|
||
Modal as AModal,
|
||
Row as ARow,
|
||
Select as ASelect,
|
||
Space,
|
||
Space as ASpace,
|
||
Spin as ASpin,
|
||
Textarea as ATextarea,
|
||
Typography,
|
||
Upload as AUpload,
|
||
} from 'ant-design-vue';
|
||
import type { UploadProps } from 'ant-design-vue';
|
||
import {
|
||
ArrowLeftOutlined,
|
||
CheckOutlined,
|
||
DeleteOutlined,
|
||
PhoneOutlined,
|
||
PlusOutlined,
|
||
SearchOutlined,
|
||
} from '@ant-design/icons-vue';
|
||
import { api } from '@/lib/api';
|
||
import type { CompanyDto, CreatePersonRequest, PersonDto, PersonGender, PersonMood } from '@mento/shared';
|
||
import { PERSON_GENDER_LABELS, PERSON_GENDERS, splitPersonHonorific } from '@mento/shared';
|
||
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
|
||
import { formatLocalDateInput, toFaDigits } from '@/lib/jalali';
|
||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||
import { useCustomerGroups } from '@/lib/customer-groups';
|
||
import {
|
||
filterPhoneContacts,
|
||
isContactPickerSupported,
|
||
parseVCardFile,
|
||
pickPhoneContacts,
|
||
sortPhoneContacts,
|
||
type PhoneContactDraft,
|
||
} from '@/lib/phone-contacts';
|
||
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
|
||
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
|
||
import EntityTimeline from '@/components/EntityTimeline.vue';
|
||
import MentoMemoryPanel from '@/components/MentoMemoryPanel.vue';
|
||
|
||
const ASpaceCompact = Space.Compact;
|
||
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
|
||
Typography;
|
||
const AListItem = AList.Item;
|
||
const AListItemMeta = AList.Item.Meta;
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
embedded?: boolean;
|
||
entityId?: string;
|
||
/** متن جستجو برای پیشپر کردن نام یا موبایل شخص جدید */
|
||
initialQuery?: string;
|
||
}>(),
|
||
{ embedded: false },
|
||
);
|
||
const emit = defineEmits<{ saved: [person?: PersonDto]; cancel: [] }>();
|
||
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
const id = computed(() => {
|
||
if (props.embedded) return props.entityId ?? '';
|
||
return typeof route.params.id === 'string' ? route.params.id : '';
|
||
});
|
||
const isNew = computed(() => (props.embedded ? !props.entityId : route.name === 'person-new'));
|
||
const pageTitle = computed(() => (isNew.value ? 'شخص جدید' : 'ویرایش شخص'));
|
||
|
||
const loading = ref(false);
|
||
const saving = ref(false);
|
||
const error = ref('');
|
||
const companies = ref<CompanyDto[]>([]);
|
||
|
||
const firstName = ref('');
|
||
const lastName = ref('');
|
||
const gender = ref<PersonGender>('none');
|
||
const showGenderInName = ref(false);
|
||
const email = ref('');
|
||
const phone = ref('');
|
||
const mobile = ref('');
|
||
const position = ref('');
|
||
const website = ref('');
|
||
const instagram = ref('');
|
||
const birthDate = ref('');
|
||
const referrer = ref('');
|
||
const addresses = ref<string[]>(['']);
|
||
const notes = ref('');
|
||
const tagsRaw = ref('');
|
||
const companyId = ref('');
|
||
const mood = ref<PersonMood>('newcomer');
|
||
const groupIds = ref<string[]>([]);
|
||
const customFields = ref<Record<string, unknown>>({});
|
||
const { groups: customerGroups, loaded: groupsLoaded, load: loadCustomerGroups } =
|
||
useCustomerGroups();
|
||
const groupOptions = computed(() =>
|
||
customerGroups.value.map((g) => ({ value: g.id, label: g.name })),
|
||
);
|
||
const loyaltyPoints = ref(0);
|
||
const loyaltyTierName = ref('');
|
||
|
||
const contactPickerSupported = isContactPickerSupported();
|
||
const pickingContact = ref(false);
|
||
const sourceOpen = ref(false);
|
||
const pickOpen = ref(false);
|
||
const pickDrafts = ref<PhoneContactDraft[]>([]);
|
||
const pickQuery = ref('');
|
||
const vcfInputKey = ref(0);
|
||
|
||
const visiblePickDrafts = computed(() =>
|
||
filterPhoneContacts(pickDrafts.value, pickQuery.value),
|
||
);
|
||
|
||
const companyOptions = computed(() => [
|
||
{ value: '', label: 'بدون شرکت' },
|
||
...companies.value.map((c) => ({ value: c.id, label: c.name })),
|
||
]);
|
||
|
||
const genderOptions = PERSON_GENDERS.map((key) => ({
|
||
value: key,
|
||
label: PERSON_GENDER_LABELS[key],
|
||
}));
|
||
|
||
watch(gender, (value) => {
|
||
if (value === 'none') showGenderInName.value = false;
|
||
});
|
||
|
||
const backTo = computed(() =>
|
||
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;
|
||
}
|
||
}
|
||
|
||
function draftLine(row: PhoneContactDraft) {
|
||
return [row.mobile, row.phone, row.email].filter(Boolean).join(' · ') || 'بدون شماره/ایمیل';
|
||
}
|
||
|
||
function closePick() {
|
||
pickOpen.value = false;
|
||
pickDrafts.value = [];
|
||
pickQuery.value = '';
|
||
}
|
||
|
||
function closeSource() {
|
||
sourceOpen.value = false;
|
||
}
|
||
|
||
function choosePick(row: PhoneContactDraft) {
|
||
applyContactDraft(row);
|
||
closePick();
|
||
}
|
||
|
||
async function openPickList(rows: PhoneContactDraft[]) {
|
||
pickDrafts.value = sortPhoneContacts(rows);
|
||
pickQuery.value = '';
|
||
sourceOpen.value = false;
|
||
pickOpen.value = false;
|
||
await nextTick();
|
||
pickOpen.value = true;
|
||
}
|
||
|
||
function fillFromPhoneContact() {
|
||
if (pickingContact.value || saving.value) return;
|
||
error.value = '';
|
||
if (contactPickerSupported) {
|
||
sourceOpen.value = true;
|
||
return;
|
||
}
|
||
vcfInputKey.value += 1;
|
||
void nextTick(() => triggerVcfPick());
|
||
}
|
||
|
||
const vcfUploadRef = ref<{ $el?: HTMLElement } | null>(null);
|
||
|
||
function triggerVcfPick() {
|
||
const root = vcfUploadRef.value?.$el as HTMLElement | undefined;
|
||
const input = root?.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||
input?.click();
|
||
}
|
||
|
||
const onVcfBeforeUpload: UploadProps['beforeUpload'] = (file) => {
|
||
void (async () => {
|
||
pickingContact.value = true;
|
||
error.value = '';
|
||
try {
|
||
const rows = await parseVCardFile(file as File);
|
||
if (!rows.length) {
|
||
error.value = 'مخاطبی در فایل پیدا نشد.';
|
||
return;
|
||
}
|
||
await openPickList(rows);
|
||
} catch {
|
||
error.value = 'خواندن فایل مخاطب (.vcf) ناموفق بود.';
|
||
} finally {
|
||
pickingContact.value = false;
|
||
}
|
||
})();
|
||
return false;
|
||
};
|
||
|
||
async function pickFromDevice() {
|
||
if (pickingContact.value || saving.value) return;
|
||
pickingContact.value = true;
|
||
error.value = '';
|
||
sourceOpen.value = false;
|
||
try {
|
||
const rows = await pickPhoneContacts({ multiple: true });
|
||
if (!rows.length) {
|
||
error.value = 'مخاطبی انتخاب نشد.';
|
||
return;
|
||
}
|
||
await openPickList(rows);
|
||
} 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') {
|
||
triggerVcfPick();
|
||
return;
|
||
}
|
||
error.value = 'انتخاب مخاطب از گوشی ناموفق بود.';
|
||
} finally {
|
||
pickingContact.value = false;
|
||
}
|
||
}
|
||
|
||
function pickFromVcf() {
|
||
sourceOpen.value = false;
|
||
triggerVcfPick();
|
||
}
|
||
|
||
function fill(p: PersonDto) {
|
||
const bare = splitPersonHonorific(p.firstName, p.lastName);
|
||
firstName.value = bare.firstName;
|
||
lastName.value = bare.lastName;
|
||
gender.value = p.gender && p.gender !== 'none' ? p.gender : (bare.gender ?? 'none');
|
||
showGenderInName.value =
|
||
gender.value !== 'none' && (p.showGenderInName || bare.gender != null);
|
||
email.value = p.email ?? '';
|
||
phone.value = p.phone ?? '';
|
||
mobile.value = p.mobile ?? '';
|
||
position.value = p.position ?? '';
|
||
website.value = p.website ?? '';
|
||
instagram.value = p.instagram ?? '';
|
||
birthDate.value = p.birthDate ? formatLocalDateInput(new Date(p.birthDate)) : '';
|
||
referrer.value = p.referrer ?? '';
|
||
addresses.value = p.addresses?.length ? [...p.addresses] : [''];
|
||
notes.value = p.notes ?? '';
|
||
tagsRaw.value = (p.tags ?? []).join('، ');
|
||
companyId.value = p.company?.id ?? '';
|
||
mood.value = p.mood || 'newcomer';
|
||
groupIds.value = (p.groups ?? []).map((g) => g.id);
|
||
customFields.value = { ...(p.customFields ?? {}) };
|
||
loyaltyPoints.value = p.loyaltyPoints ?? 0;
|
||
loyaltyTierName.value = p.loyaltyTier?.name ?? '';
|
||
}
|
||
|
||
function addAddress() {
|
||
addresses.value.push('');
|
||
}
|
||
|
||
function removeAddress(index: number) {
|
||
if (addresses.value.length <= 1) {
|
||
addresses.value[0] = '';
|
||
return;
|
||
}
|
||
addresses.value.splice(index, 1);
|
||
}
|
||
|
||
async function load() {
|
||
loading.value = true;
|
||
error.value = '';
|
||
void loadCustomerGroups().catch(() => undefined);
|
||
try {
|
||
const companiesRes = await api.get<{ items: CompanyDto[] }>('/companies', {
|
||
params: { limit: 100 },
|
||
});
|
||
companies.value = companiesRes.data.items;
|
||
if (!isNew.value && id.value) {
|
||
const { data } = await api.get<PersonDto>(`/people/${id.value}`);
|
||
fill(data);
|
||
} else if (props.initialQuery?.trim()) {
|
||
prefillFromQuery(props.initialQuery.trim());
|
||
}
|
||
} catch {
|
||
error.value = 'بارگذاری فرم ناموفق بود.';
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function prefillFromQuery(text: string) {
|
||
const digits = text.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d))).replace(/[\s-]/g, '');
|
||
if (/^\+?\d{5,}$/.test(digits)) {
|
||
mobile.value = digits;
|
||
return;
|
||
}
|
||
const [first, ...rest] = text.split(/\s+/);
|
||
firstName.value = first ?? '';
|
||
lastName.value = rest.join(' ');
|
||
}
|
||
|
||
function parseTags(raw: string): string[] {
|
||
return [
|
||
...new Set(
|
||
raw
|
||
.split(/[,،#\n]/)
|
||
.map((t) => t.trim())
|
||
.filter(Boolean),
|
||
),
|
||
].slice(0, 40);
|
||
}
|
||
|
||
function payload(): CreatePersonRequest {
|
||
return {
|
||
firstName: firstName.value,
|
||
lastName: lastName.value,
|
||
gender: gender.value,
|
||
showGenderInName: gender.value !== 'none' && showGenderInName.value,
|
||
email: email.value || undefined,
|
||
phone: phone.value || undefined,
|
||
mobile: mobile.value || undefined,
|
||
position: position.value || undefined,
|
||
website: website.value || undefined,
|
||
instagram: instagram.value || undefined,
|
||
birthDate: birthDate.value || null,
|
||
referrer: referrer.value || undefined,
|
||
addresses: addresses.value.map((a) => a.trim()).filter(Boolean),
|
||
notes: notes.value || undefined,
|
||
tags: parseTags(tagsRaw.value),
|
||
companyId: companyId.value || null,
|
||
mood: mood.value,
|
||
groupIds: groupIds.value,
|
||
customFields: customFields.value,
|
||
};
|
||
}
|
||
|
||
async function submit() {
|
||
saving.value = true;
|
||
error.value = '';
|
||
try {
|
||
if (isNew.value) {
|
||
const { data } = await api.post<PersonDto>('/people', payload());
|
||
if (props.embedded) {
|
||
emit('saved', data);
|
||
return;
|
||
}
|
||
await router.replace({ name: 'person-detail', params: { id: data.id } });
|
||
} else {
|
||
await api.patch(`/people/${id.value}`, payload());
|
||
if (props.embedded) {
|
||
emit('saved');
|
||
return;
|
||
}
|
||
await router.push({ name: 'person-detail', params: { id: id.value } });
|
||
}
|
||
} catch (e) {
|
||
if (axios.isAxiosError(e)) {
|
||
if (!e.response) {
|
||
error.value = 'اتصال به سرور برقرار نشد.';
|
||
} else {
|
||
const msg = (e.response.data as { message?: string | string[] })?.message;
|
||
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ذخیره ناموفق بود');
|
||
}
|
||
} else {
|
||
error.value = 'ذخیره ناموفق بود';
|
||
}
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
|
||
function remove() {
|
||
if (!id.value) return;
|
||
Modal.confirm({
|
||
title: 'حذف شخص',
|
||
content: 'این شخص حذف شود؟',
|
||
okText: 'حذف',
|
||
cancelText: 'انصراف',
|
||
okButtonProps: { danger: true },
|
||
async onOk() {
|
||
try {
|
||
await api.delete(`/people/${id.value}`);
|
||
await router.push({ name: 'contacts' });
|
||
} catch {
|
||
error.value = 'حذف ناموفق بود';
|
||
return Promise.reject();
|
||
}
|
||
},
|
||
});
|
||
}
|
||
|
||
function cancel() {
|
||
if (props.embedded) {
|
||
emit('cancel');
|
||
return;
|
||
}
|
||
void router.push(backTo.value);
|
||
}
|
||
|
||
onMounted(load);
|
||
</script>
|
||
|
||
<template>
|
||
<div>
|
||
<a-flex
|
||
v-if="!embedded"
|
||
justify="space-between"
|
||
align="center"
|
||
wrap="wrap"
|
||
:gap="12"
|
||
:style="{ marginBottom: '16px' }"
|
||
>
|
||
<div>
|
||
<a-typography-text type="secondary">
|
||
<RouterLink :to="{ name: 'contacts' }">مخاطبین</RouterLink>
|
||
· {{ pageTitle }}
|
||
</a-typography-text>
|
||
<a-typography-title :level="3" :style="{ margin: '4px 0 0' }">{{ pageTitle }}</a-typography-title>
|
||
</div>
|
||
<a-space :size="8" wrap>
|
||
<a-button @click="cancel">
|
||
<template #icon><ArrowLeftOutlined /></template>
|
||
انصراف
|
||
</a-button>
|
||
<a-button v-if="!isNew" danger :disabled="saving" @click="remove">
|
||
<template #icon><DeleteOutlined /></template>
|
||
حذف
|
||
</a-button>
|
||
<a-button type="primary" :loading="saving" @click="submit">
|
||
<template #icon><CheckOutlined /></template>
|
||
ذخیره
|
||
</a-button>
|
||
</a-space>
|
||
</a-flex>
|
||
|
||
<a-spin :spinning="loading">
|
||
<a-form v-if="!loading" layout="vertical" @finish="submit">
|
||
<a-card :bordered="false">
|
||
<a-divider orientation="right">اطلاعات اصلی</a-divider>
|
||
<a-row :gutter="[16, 0]">
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="نام">
|
||
<a-input v-model:value="firstName" />
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="نام خانوادگی">
|
||
<a-input v-model:value="lastName" />
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="جنسیت">
|
||
<a-select v-model:value="gender" :options="genderOptions" />
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item
|
||
label="نمایش در نام"
|
||
extra="مرد «جناب آقای» و زن «سرکار خانم» ابتدای نام ذخیره میشود و به تهحساب هم همینطور میرود."
|
||
>
|
||
<a-checkbox v-model:checked="showGenderInName" :disabled="gender === 'none'">
|
||
نمایش در نام
|
||
</a-checkbox>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="موبایل">
|
||
<a-space-compact block style="width: 100%">
|
||
<a-input v-model:value="mobile" type="tel" style="width: 100%" />
|
||
<a-button
|
||
:disabled="pickingContact || saving"
|
||
@click="fillFromPhoneContact"
|
||
>
|
||
<template #icon><PhoneOutlined /></template>
|
||
{{ pickingContact ? '…' : 'از مخاطبین' }}
|
||
</a-button>
|
||
</a-space-compact>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="تلفن">
|
||
<a-input v-model:value="phone" type="tel" />
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="ایمیل">
|
||
<a-input v-model:value="email" type="email" />
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="سمت">
|
||
<a-input v-model:value="position" />
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="آدرس وبسایت">
|
||
<a-input v-model:value="website" dir="ltr" placeholder="https://example.com" />
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="پیج اینستاگرام">
|
||
<a-input v-model:value="instagram" dir="ltr" placeholder="@username یا لینک" />
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="تاریخ تولد">
|
||
<JalaliDateInput id="birth-date" v-model="birthDate" mode="date" />
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="معرف">
|
||
<a-input v-model:value="referrer" placeholder="نام معرف" />
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
|
||
<a-divider orientation="right">آدرسها</a-divider>
|
||
<a-flex vertical :gap="8" :style="{ marginBottom: '8px' }">
|
||
<a-flex justify="flex-end">
|
||
<a-button type="link" @click="addAddress">
|
||
<template #icon><PlusOutlined /></template>
|
||
افزودن آدرس
|
||
</a-button>
|
||
</a-flex>
|
||
<a-row v-for="(_, idx) in addresses" :key="idx" :gutter="[8, 0]">
|
||
<a-col :flex="'auto'">
|
||
<a-input
|
||
v-model:value="addresses[idx]"
|
||
:placeholder="idx === 0 ? 'آدرس اصلی' : `آدرس ${idx + 1}`"
|
||
/>
|
||
</a-col>
|
||
<a-col>
|
||
<a-button danger @click="removeAddress(idx)">
|
||
<template #icon><DeleteOutlined /></template>
|
||
</a-button>
|
||
</a-col>
|
||
</a-row>
|
||
</a-flex>
|
||
|
||
<a-divider orientation="right">وضعیت و شرکت</a-divider>
|
||
<a-form-item label="وضعیت مشتری">
|
||
<a-row :gutter="[8, 8]">
|
||
<a-col v-for="item in PERSON_MOODS" :key="item" :xs="12" :sm="6">
|
||
<a-button
|
||
block
|
||
:type="mood === item ? 'primary' : 'default'"
|
||
:ghost="mood === item"
|
||
:style="{ height: 'auto', paddingBlock: '12px' }"
|
||
@click="mood = item"
|
||
>
|
||
<a-flex vertical align="center" :gap="6">
|
||
<CustomerMoodFace :mood="item" :size="40" />
|
||
<span>{{ PERSON_MOOD_LABELS[item] }}</span>
|
||
</a-flex>
|
||
</a-button>
|
||
</a-col>
|
||
</a-row>
|
||
</a-form-item>
|
||
|
||
<a-row :gutter="[16, 0]">
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="شرکت">
|
||
<a-select
|
||
v-model:value="companyId"
|
||
show-search
|
||
option-filter-prop="label"
|
||
:options="companyOptions"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="گروهها">
|
||
<a-select
|
||
v-model:value="groupIds"
|
||
mode="multiple"
|
||
allow-clear
|
||
option-filter-prop="label"
|
||
placeholder="بدون گروه"
|
||
:options="groupOptions"
|
||
:not-found-content="groupsLoaded ? 'گروهی تعریف نشده' : undefined"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="برچسبها">
|
||
<a-input
|
||
v-model:value="tagsRaw"
|
||
placeholder="VIP، معرفی (با کاما جدا کنید)"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :span="24">
|
||
<a-form-item label="یادداشت">
|
||
<a-textarea v-model:value="notes" :rows="3" />
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
|
||
<a-card
|
||
v-if="!isNew"
|
||
:bordered="true"
|
||
:style="{ marginTop: '8px' }"
|
||
type="inner"
|
||
>
|
||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
|
||
<div>
|
||
<a-typography-text type="secondary">باشگاه مشتریان</a-typography-text>
|
||
<div>
|
||
<a-typography-text strong>
|
||
{{ toFaDigits(loyaltyPoints) }} امتیاز
|
||
<template v-if="loyaltyTierName"> · سطح {{ loyaltyTierName }}</template>
|
||
</a-typography-text>
|
||
</div>
|
||
</div>
|
||
<RouterLink :to="{ name: 'loyalty' }">
|
||
<a-button type="link">مدیریت باشگاه</a-button>
|
||
</RouterLink>
|
||
</a-flex>
|
||
</a-card>
|
||
|
||
<CustomFieldsSection entity-type="person" v-model="customFields" />
|
||
</a-card>
|
||
|
||
<MentoMemoryPanel v-if="!isNew && id" :person-id="id" />
|
||
<EntityTimeline v-if="!isNew && id" entity-type="person" :entity-id="id" />
|
||
|
||
<a-alert
|
||
v-if="error"
|
||
type="error"
|
||
show-icon
|
||
:message="error"
|
||
:style="{ marginTop: '16px' }"
|
||
/>
|
||
|
||
<a-space
|
||
v-if="embedded"
|
||
wrap
|
||
:size="8"
|
||
:style="{ marginTop: '16px' }"
|
||
>
|
||
<a-button type="primary" :loading="saving" @click="submit">ذخیره</a-button>
|
||
<a-button :disabled="saving" @click="cancel">انصراف</a-button>
|
||
</a-space>
|
||
|
||
<a-space
|
||
v-else
|
||
wrap
|
||
:size="8"
|
||
:style="{ marginTop: '16px' }"
|
||
>
|
||
<a-button type="primary" html-type="submit" :loading="saving">ذخیره</a-button>
|
||
<a-button v-if="!isNew" danger :disabled="saving" @click="remove">حذف</a-button>
|
||
</a-space>
|
||
</a-form>
|
||
</a-spin>
|
||
|
||
<a-upload
|
||
ref="vcfUploadRef"
|
||
:key="vcfInputKey"
|
||
:show-upload-list="false"
|
||
accept=".vcf,text/vcard,text/x-vcard"
|
||
:before-upload="onVcfBeforeUpload"
|
||
:style="{ display: 'none' }"
|
||
>
|
||
<span />
|
||
</a-upload>
|
||
|
||
<a-modal
|
||
:open="sourceOpen"
|
||
title="از مخاطبین"
|
||
:footer="null"
|
||
destroy-on-close
|
||
@cancel="closeSource"
|
||
>
|
||
<a-typography-paragraph type="secondary">
|
||
چند مخاطب را از دفترچه یا فایل بگیرید؛ بعد در لیست الفبایی جستجو کنید و یکی را برای پر کردن موبایل انتخاب کنید.
|
||
</a-typography-paragraph>
|
||
<a-flex vertical :gap="8">
|
||
<a-button type="primary" block :loading="pickingContact" @click="pickFromDevice">
|
||
<template #icon><PhoneOutlined /></template>
|
||
از دفترچه تلفن
|
||
</a-button>
|
||
<a-button block :disabled="pickingContact" @click="pickFromVcf">از فایل .vcf</a-button>
|
||
<a-button type="link" @click="closeSource">انصراف</a-button>
|
||
</a-flex>
|
||
</a-modal>
|
||
|
||
<a-modal
|
||
:open="pickOpen"
|
||
title="انتخاب مخاطب"
|
||
destroy-on-close
|
||
@cancel="closePick"
|
||
>
|
||
<a-typography-paragraph type="secondary">
|
||
{{ toFaDigits(pickDrafts.length) }} مخاطب (مرتبشده الفبایی).
|
||
یکی را انتخاب کنید تا فقط موبایل (و در صورت خالی بودن، نام) پر شود.
|
||
</a-typography-paragraph>
|
||
<a-input
|
||
v-model:value="pickQuery"
|
||
allow-clear
|
||
placeholder="جستجو نام یا موبایل…"
|
||
:style="{ marginBottom: '12px' }"
|
||
>
|
||
<template #prefix><SearchOutlined /></template>
|
||
</a-input>
|
||
<a-list
|
||
v-if="visiblePickDrafts.length"
|
||
:data-source="visiblePickDrafts"
|
||
:style="{ maxHeight: '360px', overflow: 'auto' }"
|
||
>
|
||
<template #renderItem="{ item }">
|
||
<a-list-item>
|
||
<a-button type="text" block style="text-align: start; height: auto" @click="choosePick(item)">
|
||
<a-list-item-meta :title="item.fullName" :description="draftLine(item)" />
|
||
</a-button>
|
||
</a-list-item>
|
||
</template>
|
||
</a-list>
|
||
<a-typography-text v-else type="secondary">مخاطبی با این جستجو پیدا نشد.</a-typography-text>
|
||
<template #footer>
|
||
<a-button @click="closePick">انصراف</a-button>
|
||
<a-button @click="triggerVcfPick">فایل دیگر</a-button>
|
||
</template>
|
||
</a-modal>
|
||
</div>
|
||
</template>
|