Files
CRM_Montzar/apps/web/src/views/PersonFormView.vue
T
MahdiandCursor b9bb2ff9fa feat(contacts): keep Tahesab honorifics with person gender
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>
2026-10-29 18:46:10 +03:30

762 lines
25 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.
This file contains Unicode characters that might be confused with other characters. 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, 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>