feat(leads): keep gender on leads and form submissions
A lead can carry the same honorific as a person, and a form that asks gender writes it onto the lead. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
ALTER TABLE "Lead" ADD COLUMN "gender" TEXT NOT NULL DEFAULT 'none';
|
||||
ALTER TABLE "Lead" ADD COLUMN "showGenderInName" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- عنوان داخل نام مخاطب، و اگر مخاطب خالی است داخل عنوان لید
|
||||
UPDATE "Lead"
|
||||
SET "gender" = 'male', "showGenderInName" = true
|
||||
WHERE regexp_replace(
|
||||
regexp_replace(
|
||||
btrim(CASE WHEN btrim(coalesce("contactName", '')) <> '' THEN "contactName" ELSE "title" END),
|
||||
'ي',
|
||||
'ی',
|
||||
'g'
|
||||
),
|
||||
'ك',
|
||||
'ک',
|
||||
'g'
|
||||
) ~ '^جناب[[:space:]]*آقای([[:space:]]|$)';
|
||||
|
||||
UPDATE "Lead"
|
||||
SET "gender" = 'female', "showGenderInName" = true
|
||||
WHERE "gender" = 'none'
|
||||
AND regexp_replace(
|
||||
regexp_replace(
|
||||
btrim(CASE WHEN btrim(coalesce("contactName", '')) <> '' THEN "contactName" ELSE "title" END),
|
||||
'ي',
|
||||
'ی',
|
||||
'g'
|
||||
),
|
||||
'ك',
|
||||
'ک',
|
||||
'g'
|
||||
) ~ '^سرکار[[:space:]]*خانم([[:space:]]|$)';
|
||||
@@ -3023,6 +3023,10 @@ model Lead {
|
||||
title String
|
||||
/// نام مالک یا مسئول
|
||||
contactName String?
|
||||
/// male | female | none
|
||||
gender String @default("none")
|
||||
/// عنوان «جناب آقای» / «سرکار خانم» داخل نام مخاطب
|
||||
showGenderInName Boolean @default(false)
|
||||
mobile String
|
||||
phone String?
|
||||
city String?
|
||||
|
||||
@@ -32,6 +32,8 @@ const MAP_TO = [
|
||||
'lead.trade',
|
||||
'lead.notes',
|
||||
'lead.source',
|
||||
'lead.gender',
|
||||
'person.gender',
|
||||
'notes',
|
||||
'custom',
|
||||
] as const;
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
SubmitLeadFormResponse,
|
||||
UpdateLeadFormRequest,
|
||||
} from '@mento/shared';
|
||||
import { isGenderQuestionLabel, parsePersonGenderAnswer } from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
@@ -548,6 +549,7 @@ export class LeadFormsService {
|
||||
trade: emptyToNull(mapped.trade) ?? existingLead.trade,
|
||||
source: source ?? existingLead.source,
|
||||
notes: emptyToNull(mapped.notes) ?? existingLead.notes,
|
||||
...(mapped.gender ? { gender: mapped.gender } : {}),
|
||||
assigneeId: existingLead.assigneeId ?? assigneeId,
|
||||
lastContactAt: new Date(),
|
||||
},
|
||||
@@ -596,6 +598,7 @@ export class LeadFormsService {
|
||||
trade: emptyToNull(mapped.trade),
|
||||
source,
|
||||
notes: emptyToNull(mapped.notes),
|
||||
gender: mapped.gender ?? 'none',
|
||||
stageId,
|
||||
position: (maxPos._max.position ?? -1) + 1,
|
||||
assigneeId,
|
||||
@@ -655,6 +658,7 @@ export class LeadFormsService {
|
||||
phone: phone ?? existing.phone,
|
||||
position: emptyToNull(mapped.position) ?? existing.position,
|
||||
notes: emptyToNull(mapped.notes) ?? existing.notes,
|
||||
...(mapped.gender ? { gender: mapped.gender } : {}),
|
||||
ownerId: existing.ownerId ?? ownerId,
|
||||
},
|
||||
});
|
||||
@@ -671,6 +675,7 @@ export class LeadFormsService {
|
||||
phone,
|
||||
position: emptyToNull(mapped.position),
|
||||
notes: emptyToNull(mapped.notes),
|
||||
gender: mapped.gender ?? 'none',
|
||||
ownerId,
|
||||
},
|
||||
});
|
||||
@@ -926,6 +931,7 @@ type MappedValues = {
|
||||
region?: string;
|
||||
trade?: string;
|
||||
leadSource?: string;
|
||||
gender?: 'male' | 'female' | 'none';
|
||||
};
|
||||
|
||||
function mapValues(
|
||||
@@ -946,6 +952,15 @@ function mapValues(
|
||||
throw new BadRequestException(`فیلد «${field.label}» الزامی است`);
|
||||
}
|
||||
if (!raw) continue;
|
||||
if (
|
||||
field.mapTo === 'lead.gender' ||
|
||||
field.mapTo === 'person.gender' ||
|
||||
isGenderQuestionLabel(field.label)
|
||||
) {
|
||||
const gender = parsePersonGenderAnswer(raw);
|
||||
if (gender) mapped.gender = gender;
|
||||
continue;
|
||||
}
|
||||
switch (field.mapTo) {
|
||||
case 'person.fullName': {
|
||||
const parts = raw.split(/\s+/).filter(Boolean);
|
||||
@@ -1069,7 +1084,8 @@ function collectExtras(
|
||||
for (const field of fields) {
|
||||
if (field.fieldType === 'heading' || field.mapTo !== 'custom') continue;
|
||||
const raw = (values?.[field.key] ?? '').toString().trim();
|
||||
if (raw) lines.push(`${field.label}: ${raw}`);
|
||||
if (!raw || (isGenderQuestionLabel(field.label) && parsePersonGenderAnswer(raw))) continue;
|
||||
lines.push(`${field.label}: ${raw}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { PERSON_GENDERS } from '@mento/shared';
|
||||
|
||||
export class LeadSmsDto {
|
||||
@IsString()
|
||||
@@ -26,6 +27,8 @@ export class CreateLeadDto {
|
||||
mobile!: string;
|
||||
|
||||
@IsOptional() @IsString() contactName?: string;
|
||||
@IsOptional() @IsIn(PERSON_GENDERS) gender?: (typeof PERSON_GENDERS)[number];
|
||||
@IsOptional() @IsBoolean() showGenderInName?: boolean;
|
||||
@IsOptional() @IsString() phone?: string;
|
||||
@IsOptional() @IsString() city?: string;
|
||||
@IsOptional() @IsString() region?: string;
|
||||
@@ -47,6 +50,8 @@ export class UpdateLeadDto {
|
||||
@IsOptional() @IsString() @MinLength(1) title?: string;
|
||||
@IsOptional() @IsString() @MinLength(5) mobile?: string;
|
||||
@IsOptional() @IsString() contactName?: string | null;
|
||||
@IsOptional() @IsIn(PERSON_GENDERS) gender?: (typeof PERSON_GENDERS)[number];
|
||||
@IsOptional() @IsBoolean() showGenderInName?: boolean;
|
||||
@IsOptional() @IsString() phone?: string | null;
|
||||
@IsOptional() @IsString() city?: string | null;
|
||||
@IsOptional() @IsString() region?: string | null;
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
PaginatedResponse,
|
||||
} from '@mento/shared';
|
||||
import { FILTERED_IDS_MAX } from '@mento/shared';
|
||||
import { applyLeadGenderName, applyPersonGenderTitle } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||
import { renderSmsText } from '../common/sms-text';
|
||||
@@ -341,6 +342,8 @@ export class LeadsService {
|
||||
title: string;
|
||||
mobile: string;
|
||||
contactName?: string | null;
|
||||
gender?: string | null;
|
||||
showGenderInName?: boolean | null;
|
||||
phone?: string | null;
|
||||
city?: string | null;
|
||||
region?: string | null;
|
||||
@@ -382,11 +385,22 @@ export class LeadsService {
|
||||
}
|
||||
|
||||
const registeredAt = dto.registeredAt ? new Date(dto.registeredAt) : null;
|
||||
const named = applyLeadGenderName({
|
||||
title: dto.title,
|
||||
contactName: dto.contactName,
|
||||
gender:
|
||||
dto.gender === 'male' || dto.gender === 'female' || dto.gender === 'none'
|
||||
? dto.gender
|
||||
: undefined,
|
||||
showGenderInName: dto.showGenderInName,
|
||||
});
|
||||
const row = await this.prisma.lead.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
title: dto.title.trim(),
|
||||
contactName: emptyToNull(dto.contactName),
|
||||
title: named.title,
|
||||
contactName: named.contactName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
mobile,
|
||||
phone: emptyToNull(dto.phone),
|
||||
city: emptyToNull(dto.city),
|
||||
@@ -461,12 +475,41 @@ export class LeadsService {
|
||||
}
|
||||
}
|
||||
|
||||
const touchName =
|
||||
typeof dto.title === 'string' ||
|
||||
dto.contactName !== undefined ||
|
||||
dto.gender !== undefined ||
|
||||
dto.showGenderInName !== undefined;
|
||||
const named = touchName
|
||||
? applyLeadGenderName({
|
||||
title: typeof dto.title === 'string' ? dto.title : existing.title,
|
||||
contactName:
|
||||
dto.contactName !== undefined
|
||||
? (dto.contactName as string | null)
|
||||
: existing.contactName,
|
||||
gender:
|
||||
dto.gender === 'male' || dto.gender === 'female' || dto.gender === 'none'
|
||||
? dto.gender
|
||||
: existing.gender === 'male' || existing.gender === 'female'
|
||||
? existing.gender
|
||||
: 'none',
|
||||
showGenderInName:
|
||||
typeof dto.showGenderInName === 'boolean'
|
||||
? dto.showGenderInName
|
||||
: existing.showGenderInName,
|
||||
})
|
||||
: null;
|
||||
|
||||
const data: Prisma.LeadUpdateInput = {
|
||||
...(typeof dto.title === 'string' ? { title: dto.title.trim() } : {}),
|
||||
mobile,
|
||||
...(dto.contactName !== undefined
|
||||
? { contactName: emptyToNull(dto.contactName as string) }
|
||||
...(named
|
||||
? {
|
||||
title: named.title,
|
||||
contactName: named.contactName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
}
|
||||
: {}),
|
||||
mobile,
|
||||
...(dto.phone !== undefined
|
||||
? { phone: emptyToNull(dto.phone as string) }
|
||||
: {}),
|
||||
@@ -877,13 +920,24 @@ export class LeadsService {
|
||||
? `${dto.firstName ?? ''} ${dto.lastName ?? ''}`.trim()
|
||||
: (lead.contactName || lead.title).trim();
|
||||
const nameParts = rawName.split(/\s+/).filter(Boolean);
|
||||
const firstName = nameParts[0] || lead.title;
|
||||
const lastName = nameParts.slice(1).join(' ') || '—';
|
||||
const bareFirst = nameParts[0] || lead.title;
|
||||
const bareLast = nameParts.slice(1).join(' ');
|
||||
const named = applyPersonGenderTitle({
|
||||
firstName: bareFirst,
|
||||
lastName: bareLast,
|
||||
gender:
|
||||
lead.gender === 'male' || lead.gender === 'female' || lead.gender === 'none'
|
||||
? lead.gender
|
||||
: 'none',
|
||||
showGenderInName: lead.showGenderInName,
|
||||
});
|
||||
person = await this.prisma.person.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
firstName,
|
||||
lastName,
|
||||
firstName: named.firstName,
|
||||
lastName: named.lastName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
mobile: lead.mobile,
|
||||
phone: lead.phone,
|
||||
instagram: lead.instagram,
|
||||
@@ -1202,6 +1256,8 @@ export class LeadsService {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
contactName: row.contactName,
|
||||
gender: row.gender === 'male' || row.gender === 'female' ? row.gender : 'none',
|
||||
showGenderInName: row.showGenderInName,
|
||||
mobile: row.mobile,
|
||||
phone: row.phone,
|
||||
city: row.city,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { applyPersonGenderTitle, splitPersonHonorific } from '@mento/shared';
|
||||
import {
|
||||
applyLeadGenderName,
|
||||
applyPersonGenderTitle,
|
||||
isGenderQuestionLabel,
|
||||
parsePersonGenderAnswer,
|
||||
splitPersonHonorific,
|
||||
} from '@mento/shared';
|
||||
|
||||
describe('عنوان جنسیت در نام شخص', () => {
|
||||
it('ی عربی جناب آقای را مرد میخواند و عنوان معیار را میگذارد', () => {
|
||||
@@ -99,3 +105,38 @@ describe('عنوان جنسیت در نام شخص', () => {
|
||||
assert.equal(`${next.firstName} ${next.lastName}`, 'جناب آقای محمد انتظاری');
|
||||
});
|
||||
});
|
||||
|
||||
describe('جنسیت لید و پاسخ فرم', () => {
|
||||
it('عنوان را روی نام مخاطب میگذارد و عنوان لید را دست نمیزند', () => {
|
||||
const named = applyLeadGenderName({
|
||||
title: 'فروشگاه الماس',
|
||||
contactName: 'مریم رضایی',
|
||||
gender: 'female',
|
||||
showGenderInName: true,
|
||||
});
|
||||
assert.equal(named.title, 'فروشگاه الماس');
|
||||
assert.equal(named.contactName, 'سرکار خانم مریم رضایی');
|
||||
assert.equal(named.gender, 'female');
|
||||
assert.equal(named.showGenderInName, true);
|
||||
});
|
||||
|
||||
it('اگر نام مخاطب خالی باشد عنوان لید پیشوند میگیرد', () => {
|
||||
const named = applyLeadGenderName({
|
||||
title: 'آرش انتظاری',
|
||||
contactName: ' ',
|
||||
gender: 'male',
|
||||
showGenderInName: true,
|
||||
});
|
||||
assert.equal(named.title, 'جناب آقای آرش انتظاری');
|
||||
assert.equal(named.contactName, null);
|
||||
});
|
||||
|
||||
it('پاسخ فرم مرد و زن و هیچکدام را میخواند و برچسب جنسیت را میشناسد', () => {
|
||||
assert.equal(parsePersonGenderAnswer('مرد'), 'male');
|
||||
assert.equal(parsePersonGenderAnswer('خانم'), 'female');
|
||||
assert.equal(parsePersonGenderAnswer('هیچکدام'), 'none');
|
||||
assert.equal(parsePersonGenderAnswer('نمیدانم'), null);
|
||||
assert.equal(isGenderQuestionLabel('جنسیت شما'), true);
|
||||
assert.equal(isGenderQuestionLabel('نام'), false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Checkbox as ACheckbox,
|
||||
Col as ACol,
|
||||
Descriptions as ADescriptions,
|
||||
DescriptionsItem as ADescriptionsItem,
|
||||
@@ -66,8 +67,9 @@ import type {
|
||||
LeadStageDto,
|
||||
OrgUserDto,
|
||||
PaginatedResponse,
|
||||
PersonGender,
|
||||
} from '@mento/shared';
|
||||
import { ACTIVITY_TYPE_LABELS } from '@mento/shared';
|
||||
import { ACTIVITY_TYPE_LABELS, PERSON_GENDERS, PERSON_GENDER_LABELS, splitPersonHonorific } from '@mento/shared';
|
||||
import { canUseBehavior } from '@/lib/behavior-access';
|
||||
import SalesCopilotPanel from '@/components/behavior/SalesCopilotPanel.vue';
|
||||
|
||||
@@ -113,6 +115,8 @@ let narrowMq: MediaQueryList | null = null;
|
||||
const editForm = ref({
|
||||
title: '',
|
||||
contactName: '',
|
||||
gender: 'none' as PersonGender,
|
||||
showGenderInName: false,
|
||||
mobile: '',
|
||||
phone: '',
|
||||
city: '',
|
||||
@@ -145,6 +149,30 @@ const answerRows = computed(() =>
|
||||
noteView.value.rows.filter((row) => !isBirthDateAnswer(row.label)),
|
||||
);
|
||||
|
||||
const genderOptions = PERSON_GENDERS.map((key) => ({
|
||||
value: key,
|
||||
label: PERSON_GENDER_LABELS[key],
|
||||
}));
|
||||
|
||||
const genderLabel = computed(() => {
|
||||
const gender = lead.value?.gender;
|
||||
const key = gender === 'male' || gender === 'female' ? gender : 'none';
|
||||
const label = PERSON_GENDER_LABELS[key];
|
||||
return lead.value?.showGenderInName && key !== 'none' ? `${label} · در نام` : label;
|
||||
});
|
||||
|
||||
function bareLeadName(value: string) {
|
||||
const split = splitPersonHonorific(value, '');
|
||||
return [split.firstName, split.lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
watch(
|
||||
() => editForm.value.gender,
|
||||
(value) => {
|
||||
if (value === 'none') editForm.value.showGenderInName = false;
|
||||
},
|
||||
);
|
||||
|
||||
function apiError(e: unknown, fallback: string) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
@@ -186,9 +214,13 @@ function startEdit() {
|
||||
if (!lead.value) return;
|
||||
editing.value = true;
|
||||
moreOpen.value = false;
|
||||
const gender =
|
||||
lead.value.gender === 'male' || lead.value.gender === 'female' ? lead.value.gender : 'none';
|
||||
editForm.value = {
|
||||
title: lead.value.title,
|
||||
contactName: lead.value.contactName ?? '',
|
||||
title: bareLeadName(lead.value.title),
|
||||
contactName: bareLeadName(lead.value.contactName ?? ''),
|
||||
gender,
|
||||
showGenderInName: !!lead.value.showGenderInName && gender !== 'none',
|
||||
mobile: lead.value.mobile,
|
||||
phone: lead.value.phone ?? '',
|
||||
city: lead.value.city ?? '',
|
||||
@@ -214,6 +246,9 @@ async function saveEdit() {
|
||||
await api.patch(`/leads/${id.value}`, {
|
||||
title: editForm.value.title.trim(),
|
||||
contactName: editForm.value.contactName.trim() || null,
|
||||
gender: editForm.value.gender,
|
||||
showGenderInName:
|
||||
editForm.value.gender !== 'none' && editForm.value.showGenderInName,
|
||||
mobile: editForm.value.mobile.trim(),
|
||||
phone: editForm.value.phone.trim() || null,
|
||||
city: editForm.value.city.trim() || null,
|
||||
@@ -553,6 +588,24 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
|
||||
<a-input v-model:value="editForm.contactName" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="جنسیت">
|
||||
<a-select v-model:value="editForm.gender" :options="genderOptions" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item
|
||||
label="نمایش در نام"
|
||||
extra="مرد «جناب آقای» و زن «سرکار خانم» ابتدای نام مخاطب ذخیره میشود. اگر نام مخاطب خالی باشد روی عنوان مینشیند و با تبدیل به مشتری به تهحساب هم میرود."
|
||||
>
|
||||
<a-checkbox
|
||||
v-model:checked="editForm.showGenderInName"
|
||||
:disabled="editForm.gender === 'none'"
|
||||
>
|
||||
نمایش در نام
|
||||
</a-checkbox>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="موبایل" required>
|
||||
<a-input
|
||||
@@ -666,6 +719,7 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
|
||||
<a-descriptions-item label="مالک/مخاطب">
|
||||
{{ lead.contactName || '—' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="جنسیت">{{ genderLabel }}</a-descriptions-item>
|
||||
<a-descriptions-item label="موبایل">
|
||||
<span dir="ltr">{{ lead.mobile }}</span>
|
||||
</a-descriptions-item>
|
||||
|
||||
@@ -997,6 +997,7 @@ onMounted(load);
|
||||
<option value="lead.trade">صنف</option>
|
||||
<option value="lead.notes">یادداشت لید</option>
|
||||
<option value="lead.source">منبع لید</option>
|
||||
<option value="lead.gender">جنسیت</option>
|
||||
</optgroup>
|
||||
<optgroup label="شخص">
|
||||
<option value="person.fullName">نام کامل</option>
|
||||
@@ -1006,6 +1007,7 @@ onMounted(load);
|
||||
<option value="person.email">ایمیل</option>
|
||||
<option value="person.phone">تلفن</option>
|
||||
<option value="person.position">سمت</option>
|
||||
<option value="person.gender">جنسیت شخص</option>
|
||||
</optgroup>
|
||||
<optgroup label="معامله">
|
||||
<option value="deal.title">عنوان معامله</option>
|
||||
|
||||
@@ -77,9 +77,10 @@ import type {
|
||||
LeadStageDto,
|
||||
LeadStatsDto,
|
||||
OrgUserDto,
|
||||
PersonGender,
|
||||
PaginatedResponse,
|
||||
} from '@mento/shared';
|
||||
import { brand } from '@mento/shared';
|
||||
import { brand, PERSON_GENDERS, PERSON_GENDER_LABELS, splitPersonHonorific } from '@mento/shared';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
|
||||
Typography;
|
||||
@@ -191,6 +192,8 @@ const editingLeadId = ref<string | null>(null);
|
||||
const createForm = ref({
|
||||
title: '',
|
||||
contactName: '',
|
||||
gender: 'none' as PersonGender,
|
||||
showGenderInName: false,
|
||||
mobile: '',
|
||||
city: '',
|
||||
region: '',
|
||||
@@ -656,12 +659,31 @@ const createModalTitle = computed(() =>
|
||||
editingLeadId.value ? 'ویرایش لید' : 'ثبت لید جدید',
|
||||
);
|
||||
|
||||
const genderOptions = PERSON_GENDERS.map((key) => ({
|
||||
value: key,
|
||||
label: PERSON_GENDER_LABELS[key],
|
||||
}));
|
||||
|
||||
function bareLeadName(value: string) {
|
||||
const split = splitPersonHonorific(value, '');
|
||||
return [split.firstName, split.lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
watch(
|
||||
() => createForm.value.gender,
|
||||
(value) => {
|
||||
if (value === 'none') createForm.value.showGenderInName = false;
|
||||
},
|
||||
);
|
||||
|
||||
function openCreate() {
|
||||
editingLeadId.value = null;
|
||||
createError.value = '';
|
||||
createForm.value = {
|
||||
title: '',
|
||||
contactName: '',
|
||||
gender: 'none',
|
||||
showGenderInName: false,
|
||||
mobile: '',
|
||||
city: '',
|
||||
region: '',
|
||||
@@ -693,8 +715,10 @@ function openEdit(lead: LeadDto) {
|
||||
editingLeadId.value = lead.id;
|
||||
createError.value = '';
|
||||
createForm.value = {
|
||||
title: lead.title,
|
||||
contactName: lead.contactName ?? '',
|
||||
title: bareLeadName(lead.title),
|
||||
contactName: bareLeadName(lead.contactName ?? ''),
|
||||
gender: lead.gender && lead.gender !== 'none' ? lead.gender : 'none',
|
||||
showGenderInName: !!lead.showGenderInName && lead.gender !== 'none',
|
||||
mobile: lead.mobile,
|
||||
city: lead.city ?? '',
|
||||
region: lead.region ?? '',
|
||||
@@ -857,6 +881,8 @@ async function submitCreate() {
|
||||
title: company || person || mobile,
|
||||
mobile,
|
||||
contactName: person || null,
|
||||
gender: createForm.value.gender,
|
||||
showGenderInName: createForm.value.gender !== 'none' && createForm.value.showGenderInName,
|
||||
city: createForm.value.city.trim() || null,
|
||||
region: createForm.value.region.trim() || null,
|
||||
source: createForm.value.source.trim() || null,
|
||||
@@ -1629,6 +1655,24 @@ onBeforeUnmount(() => {
|
||||
<a-input v-model:value="createForm.contactName" placeholder="مهدی احمدی" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="جنسیت">
|
||||
<a-select v-model:value="createForm.gender" :options="genderOptions" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item
|
||||
label="نمایش در نام"
|
||||
extra="مرد «جناب آقای» و زن «سرکار خانم» ابتدای نام مخاطب ذخیره میشود. اگر نام مخاطب خالی باشد روی عنوان مینشیند و با تبدیل به مشتری به تهحساب هم میرود."
|
||||
>
|
||||
<a-checkbox
|
||||
v-model:checked="createForm.showGenderInName"
|
||||
:disabled="createForm.gender === 'none'"
|
||||
>
|
||||
نمایش در نام
|
||||
</a-checkbox>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="موبایل">
|
||||
<template #extra>
|
||||
|
||||
@@ -3,7 +3,10 @@ export type { PersonGender } from "./person-name";
|
||||
export {
|
||||
PERSON_GENDERS,
|
||||
PERSON_GENDER_LABELS,
|
||||
applyLeadGenderName,
|
||||
applyPersonGenderTitle,
|
||||
isGenderQuestionLabel,
|
||||
parsePersonGenderAnswer,
|
||||
splitPersonHonorific,
|
||||
} from "./person-name";
|
||||
|
||||
@@ -2432,6 +2435,8 @@ export type LeadFormMapTo =
|
||||
| "lead.trade"
|
||||
| "lead.notes"
|
||||
| "lead.source"
|
||||
| "lead.gender"
|
||||
| "person.gender"
|
||||
| "notes"
|
||||
| "custom";
|
||||
|
||||
@@ -5470,6 +5475,8 @@ export interface LeadDto {
|
||||
id: string;
|
||||
title: string;
|
||||
contactName: string | null;
|
||||
gender: PersonGender;
|
||||
showGenderInName: boolean;
|
||||
mobile: string;
|
||||
phone: string | null;
|
||||
city: string | null;
|
||||
@@ -5510,6 +5517,8 @@ export interface CreateLeadRequest {
|
||||
title: string;
|
||||
mobile: string;
|
||||
contactName?: string | null;
|
||||
gender?: PersonGender;
|
||||
showGenderInName?: boolean;
|
||||
phone?: string | null;
|
||||
city?: string | null;
|
||||
region?: string | null;
|
||||
@@ -5531,6 +5540,8 @@ export interface UpdateLeadRequest {
|
||||
title?: string;
|
||||
mobile?: string;
|
||||
contactName?: string | null;
|
||||
gender?: PersonGender;
|
||||
showGenderInName?: boolean;
|
||||
phone?: string | null;
|
||||
city?: string | null;
|
||||
region?: string | null;
|
||||
|
||||
@@ -84,3 +84,51 @@ export function applyPersonGenderTitle(input: {
|
||||
showGenderInName: show,
|
||||
};
|
||||
}
|
||||
|
||||
/** پاسخ فیلد جنسیت فرم: مرد، زن، یا هیچکدام. مقدار ناشناس null است. */
|
||||
export function parsePersonGenderAnswer(raw: string): PersonGender | null {
|
||||
const text = normalizeFa(raw).toLowerCase();
|
||||
if (!text) return null;
|
||||
const has = (words: string[]) => words.some((word) => text === word || text.includes(word));
|
||||
if (has(["هیچکدام", "none", "نامشخص"])) return "none";
|
||||
if (has(["female", "woman", "خانم", "زن"])) return "female";
|
||||
if (has(["male", "man", "آقا", "مرد"])) return "male";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isGenderQuestionLabel(label: string): boolean {
|
||||
return normalizeFa(label).replace(/\s+/g, "").includes("جنسیت");
|
||||
}
|
||||
|
||||
/**
|
||||
* عنوان روی نام مخاطب لید مینشیند.
|
||||
* اگر نام مخاطب خالی باشد، عنوان لید همان نام شخص است.
|
||||
*/
|
||||
export function applyLeadGenderName(input: {
|
||||
title: string;
|
||||
contactName?: string | null;
|
||||
gender?: PersonGender | null;
|
||||
showGenderInName?: boolean | null;
|
||||
}): {
|
||||
title: string;
|
||||
contactName: string | null;
|
||||
gender: PersonGender;
|
||||
showGenderInName: boolean;
|
||||
} {
|
||||
const contact = (input.contactName ?? "").trim();
|
||||
const title = input.title.trim();
|
||||
const onContact = contact.length > 0;
|
||||
const named = applyPersonGenderTitle({
|
||||
firstName: onContact ? contact : title,
|
||||
lastName: "",
|
||||
gender: input.gender,
|
||||
showGenderInName: input.showGenderInName,
|
||||
});
|
||||
const full = [named.firstName, named.lastName].filter((part) => part && part !== "—").join(" ");
|
||||
return {
|
||||
title: onContact ? title : full || title,
|
||||
contactName: onContact ? full || null : null,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user