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>
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
"prisma:seed": "tsx prisma/seed.ts",
|
"prisma:seed": "tsx prisma/seed.ts",
|
||||||
"lint": "echo \"api lint placeholder\"",
|
"lint": "echo \"api lint placeholder\"",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json",
|
||||||
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts test/organizations.test.ts test/promote-from-prior.test.ts test/behavior.test.ts test/attendance-time.test.ts test/sales-factors.test.ts test/platform-admin-alert.test.ts"
|
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts test/organizations.test.ts test/promote-from-prior.test.ts test/behavior.test.ts test/attendance-time.test.ts test/sales-factors.test.ts test/platform-admin-alert.test.ts test/person-name.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mento/shared": "*",
|
"@mento/shared": "*",
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
ALTER TABLE "Person" ADD COLUMN "gender" TEXT NOT NULL DEFAULT 'none';
|
||||||
|
ALTER TABLE "Person" ADD COLUMN "showGenderInName" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- نامهایی که از تهحساب با عنوان آمدهاند؛ خود متن نام دست نمیخورد
|
||||||
|
UPDATE "Person"
|
||||||
|
SET "gender" = 'male', "showGenderInName" = true
|
||||||
|
WHERE regexp_replace(
|
||||||
|
regexp_replace(btrim("firstName" || ' ' || "lastName"), 'ي', 'ی', 'g'),
|
||||||
|
'ك',
|
||||||
|
'ک',
|
||||||
|
'g'
|
||||||
|
) ~ '^جناب[[:space:]]*آقای([[:space:]]|$)';
|
||||||
|
|
||||||
|
UPDATE "Person"
|
||||||
|
SET "gender" = 'female', "showGenderInName" = true
|
||||||
|
WHERE "gender" = 'none'
|
||||||
|
AND regexp_replace(
|
||||||
|
regexp_replace(btrim("firstName" || ' ' || "lastName"), 'ي', 'ی', 'g'),
|
||||||
|
'ك',
|
||||||
|
'ک',
|
||||||
|
'g'
|
||||||
|
) ~ '^سرکار[[:space:]]*خانم([[:space:]]|$)';
|
||||||
@@ -579,6 +579,10 @@ model Person {
|
|||||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||||
firstName String
|
firstName String
|
||||||
lastName String
|
lastName String
|
||||||
|
/// male | female | none
|
||||||
|
gender String @default("none")
|
||||||
|
/// اگر روشن باشد، مرد «جناب آقای» و زن «سرکار خانم» ابتدای نام ذخیره میشود
|
||||||
|
showGenderInName Boolean @default(false)
|
||||||
/// نام قبل از همگامسازی مبدأ (مثلاً تهحساب)
|
/// نام قبل از همگامسازی مبدأ (مثلاً تهحساب)
|
||||||
previousFirstName String?
|
previousFirstName String?
|
||||||
previousLastName String?
|
previousLastName String?
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ArrayMaxSize,
|
ArrayMaxSize,
|
||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
IsEmail,
|
IsEmail,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
@@ -15,7 +16,7 @@ import {
|
|||||||
MinLength,
|
MinLength,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { PERSON_IMPORT_SOURCES, PERSON_MOODS } from '@mento/shared';
|
import { PERSON_GENDERS, PERSON_IMPORT_SOURCES, PERSON_MOODS } from '@mento/shared';
|
||||||
|
|
||||||
export class BirthdaySmsDto {
|
export class BirthdaySmsDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -282,6 +283,14 @@ export class CreatePersonDto {
|
|||||||
@MaxLength(80)
|
@MaxLength(80)
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(PERSON_GENDERS)
|
||||||
|
gender?: (typeof PERSON_GENDERS)[number];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
showGenderInName?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ValidateIf((_, v) => v !== '' && v != null)
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -401,6 +410,14 @@ export class UpdatePersonDto {
|
|||||||
@MaxLength(80)
|
@MaxLength(80)
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(PERSON_GENDERS)
|
||||||
|
gender?: (typeof PERSON_GENDERS)[number];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
showGenderInName?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ValidateIf((_, v) => v !== '' && v != null)
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ import type {
|
|||||||
PersonMood,
|
PersonMood,
|
||||||
UpdatePersonRequest,
|
UpdatePersonRequest,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { FILTERED_IDS_MAX, PERSON_MOODS } from '@mento/shared';
|
import {
|
||||||
|
FILTERED_IDS_MAX,
|
||||||
|
PERSON_MOODS,
|
||||||
|
applyPersonGenderTitle,
|
||||||
|
splitPersonHonorific,
|
||||||
|
} from '@mento/shared';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
|
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
|
||||||
@@ -73,6 +78,8 @@ type PersonRow = {
|
|||||||
id: string;
|
id: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
|
gender: string;
|
||||||
|
showGenderInName: boolean;
|
||||||
previousFirstName: string | null;
|
previousFirstName: string | null;
|
||||||
previousLastName: string | null;
|
previousLastName: string | null;
|
||||||
importSource: string;
|
importSource: string;
|
||||||
@@ -417,10 +424,18 @@ export class PeopleService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const registeredAt = dto.registeredAt ? new Date(dto.registeredAt) : null;
|
const registeredAt = dto.registeredAt ? new Date(dto.registeredAt) : null;
|
||||||
|
const named = applyPersonGenderTitle({
|
||||||
|
firstName: dto.firstName ?? '',
|
||||||
|
lastName: dto.lastName ?? '',
|
||||||
|
gender: dto.gender,
|
||||||
|
showGenderInName: dto.showGenderInName,
|
||||||
|
});
|
||||||
const data: Prisma.PersonUncheckedCreateInput = {
|
const data: Prisma.PersonUncheckedCreateInput = {
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
firstName: (dto.firstName ?? '').trim(),
|
firstName: named.firstName,
|
||||||
lastName: (dto.lastName ?? '').trim(),
|
lastName: named.lastName,
|
||||||
|
gender: named.gender,
|
||||||
|
showGenderInName: named.showGenderInName,
|
||||||
previousFirstName: emptyToNull(dto.previousFirstName),
|
previousFirstName: emptyToNull(dto.previousFirstName),
|
||||||
previousLastName: emptyToNull(dto.previousLastName),
|
previousLastName: emptyToNull(dto.previousLastName),
|
||||||
importSource: normalizePersonImportSource(dto.importSource),
|
importSource: normalizePersonImportSource(dto.importSource),
|
||||||
@@ -497,8 +512,32 @@ export class PeopleService implements OnModuleInit {
|
|||||||
await this.ensureExists(actor, id);
|
await this.ensureExists(actor, id);
|
||||||
|
|
||||||
const data: Prisma.PersonUncheckedUpdateInput = {};
|
const data: Prisma.PersonUncheckedUpdateInput = {};
|
||||||
if (dto.firstName !== undefined) data.firstName = dto.firstName.trim();
|
const touchesName =
|
||||||
if (dto.lastName !== undefined) data.lastName = dto.lastName.trim();
|
dto.firstName !== undefined ||
|
||||||
|
dto.lastName !== undefined ||
|
||||||
|
dto.gender !== undefined ||
|
||||||
|
dto.showGenderInName !== undefined;
|
||||||
|
if (touchesName) {
|
||||||
|
const existing = await this.prisma.person.findFirst({
|
||||||
|
where: { id, organizationId: actor.organizationId },
|
||||||
|
select: { firstName: true, lastName: true, gender: true, showGenderInName: true },
|
||||||
|
});
|
||||||
|
if (!existing) throw new NotFoundException('شخص پیدا نشد');
|
||||||
|
const bare = splitPersonHonorific(existing.firstName, existing.lastName);
|
||||||
|
const storedGender =
|
||||||
|
existing.gender === 'male' || existing.gender === 'female' ? existing.gender : 'none';
|
||||||
|
const named = applyPersonGenderTitle({
|
||||||
|
firstName: dto.firstName !== undefined ? dto.firstName : bare.firstName,
|
||||||
|
lastName: dto.lastName !== undefined ? dto.lastName : bare.lastName,
|
||||||
|
gender: dto.gender !== undefined ? dto.gender : storedGender,
|
||||||
|
showGenderInName:
|
||||||
|
dto.showGenderInName !== undefined ? dto.showGenderInName : existing.showGenderInName,
|
||||||
|
});
|
||||||
|
data.firstName = named.firstName;
|
||||||
|
data.lastName = named.lastName;
|
||||||
|
data.gender = named.gender;
|
||||||
|
data.showGenderInName = named.showGenderInName;
|
||||||
|
}
|
||||||
if ('previousFirstName' in dto) {
|
if ('previousFirstName' in dto) {
|
||||||
data.previousFirstName = emptyToNull(dto.previousFirstName);
|
data.previousFirstName = emptyToNull(dto.previousFirstName);
|
||||||
}
|
}
|
||||||
@@ -809,6 +848,8 @@ export class PeopleService implements OnModuleInit {
|
|||||||
previousImportSource: row.previousImportSource,
|
previousImportSource: row.previousImportSource,
|
||||||
previousSourceRef: row.previousSourceRef,
|
previousSourceRef: row.previousSourceRef,
|
||||||
fullName: `${row.firstName} ${row.lastName}`.trim(),
|
fullName: `${row.firstName} ${row.lastName}`.trim(),
|
||||||
|
gender: row.gender === 'male' || row.gender === 'female' ? row.gender : 'none',
|
||||||
|
showGenderInName: row.showGenderInName,
|
||||||
email: row.email,
|
email: row.email,
|
||||||
phone: row.phone,
|
phone: row.phone,
|
||||||
mobile: row.mobile,
|
mobile: row.mobile,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { carryPreviousSource } from '../../contacts/person-source';
|
import { carryPreviousSource } from '../../contacts/person-source';
|
||||||
|
import { applyPersonGenderTitle } from '@mento/shared';
|
||||||
import { TahesabConnectionsService } from './connections.service';
|
import { TahesabConnectionsService } from './connections.service';
|
||||||
import {
|
import {
|
||||||
CreateInTahesabDto,
|
CreateInTahesabDto,
|
||||||
@@ -573,9 +574,9 @@ export class TahesabPersonLinkService {
|
|||||||
}
|
}
|
||||||
const name = pickName(row) || `مشتری تهحساب ${dto.moshtariCode}`;
|
const name = pickName(row) || `مشتری تهحساب ${dto.moshtariCode}`;
|
||||||
const tel = pickTel(row);
|
const tel = pickTel(row);
|
||||||
const parts = name.trim().split(/\s+/);
|
const named = applyPersonGenderTitle({ firstName: name, lastName: '' });
|
||||||
const firstName = parts[0] || name;
|
const firstName = named.firstName;
|
||||||
const lastName = parts.slice(1).join(' ') || '—';
|
const lastName = named.lastName;
|
||||||
const mobile = tel || null;
|
const mobile = tel || null;
|
||||||
const sourceRef = String(dto.moshtariCode);
|
const sourceRef = String(dto.moshtariCode);
|
||||||
|
|
||||||
@@ -591,6 +592,8 @@ export class TahesabPersonLinkService {
|
|||||||
data: {
|
data: {
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
|
gender: named.gender,
|
||||||
|
showGenderInName: named.showGenderInName,
|
||||||
...(nameChanged
|
...(nameChanged
|
||||||
? {
|
? {
|
||||||
previousFirstName: prevFirst || null,
|
previousFirstName: prevFirst || null,
|
||||||
@@ -631,6 +634,8 @@ export class TahesabPersonLinkService {
|
|||||||
data: {
|
data: {
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
|
gender: named.gender,
|
||||||
|
showGenderInName: named.showGenderInName,
|
||||||
...(nameChanged
|
...(nameChanged
|
||||||
? {
|
? {
|
||||||
previousFirstName: byMobile.firstName || null,
|
previousFirstName: byMobile.firstName || null,
|
||||||
@@ -650,6 +655,8 @@ export class TahesabPersonLinkService {
|
|||||||
organizationId,
|
organizationId,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
|
gender: named.gender,
|
||||||
|
showGenderInName: named.showGenderInName,
|
||||||
mobile,
|
mobile,
|
||||||
phone: mobile,
|
phone: mobile,
|
||||||
notes: `واردشده از تهحساب (کد ${dto.moshtariCode})`,
|
notes: `واردشده از تهحساب (کد ${dto.moshtariCode})`,
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import { applyPersonGenderTitle, splitPersonHonorific } from '@mento/shared';
|
||||||
|
|
||||||
|
describe('عنوان جنسیت در نام شخص', () => {
|
||||||
|
it('ی عربی جناب آقای را مرد میخواند و عنوان معیار را میگذارد', () => {
|
||||||
|
const named = applyPersonGenderTitle({
|
||||||
|
firstName: 'جناب آقاي آرش انتظاري',
|
||||||
|
lastName: '',
|
||||||
|
});
|
||||||
|
assert.equal(named.gender, 'male');
|
||||||
|
assert.equal(named.showGenderInName, true);
|
||||||
|
assert.equal(`${named.firstName} ${named.lastName}`, 'جناب آقای آرش انتظاری');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('سرکار خانم را زن میخواند، با فاصله و بیفاصله', () => {
|
||||||
|
const spaced = applyPersonGenderTitle({
|
||||||
|
firstName: 'سرکار خانم آبيدا',
|
||||||
|
lastName: 'افرندنژاد تهران',
|
||||||
|
});
|
||||||
|
assert.equal(spaced.gender, 'female');
|
||||||
|
assert.equal(spaced.showGenderInName, true);
|
||||||
|
assert.equal(`${spaced.firstName} ${spaced.lastName}`, 'سرکار خانم آبیدا افرندنژاد تهران');
|
||||||
|
|
||||||
|
const tight = applyPersonGenderTitle({
|
||||||
|
firstName: 'سرکارخانم مریم رضایی',
|
||||||
|
lastName: '',
|
||||||
|
});
|
||||||
|
assert.equal(tight.gender, 'female');
|
||||||
|
assert.equal(`${tight.firstName} ${tight.lastName}`, 'سرکار خانم مریم رضایی');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('بدون عنوان، هیچکدام میماند و چیزی اضافه نمیکند', () => {
|
||||||
|
const shop = applyPersonGenderTitle({ firstName: 'فروشگاه', lastName: 'الماس' });
|
||||||
|
assert.equal(shop.gender, 'none');
|
||||||
|
assert.equal(shop.showGenderInName, false);
|
||||||
|
assert.equal(shop.firstName, 'فروشگاه');
|
||||||
|
assert.equal(shop.lastName, 'الماس');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('تیک خاموش جنسیت را نگه میدارد ولی عنوان را برمیدارد', () => {
|
||||||
|
const hidden = applyPersonGenderTitle({
|
||||||
|
firstName: 'جناب آقای آرش',
|
||||||
|
lastName: 'انتظاری',
|
||||||
|
gender: 'male',
|
||||||
|
showGenderInName: false,
|
||||||
|
});
|
||||||
|
assert.equal(hidden.gender, 'male');
|
||||||
|
assert.equal(hidden.showGenderInName, false);
|
||||||
|
assert.equal(`${hidden.firstName} ${hidden.lastName}`, 'آرش انتظاری');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('هیچکدام عنوان را برمیدارد حتی اگر تیک روشن باشد', () => {
|
||||||
|
const cleared = applyPersonGenderTitle({
|
||||||
|
firstName: 'جناب آقای آرش',
|
||||||
|
lastName: 'انتظاری',
|
||||||
|
gender: 'none',
|
||||||
|
showGenderInName: true,
|
||||||
|
});
|
||||||
|
assert.equal(cleared.gender, 'none');
|
||||||
|
assert.equal(cleared.showGenderInName, false);
|
||||||
|
assert.equal(`${cleared.firstName} ${cleared.lastName}`, 'آرش انتظاری');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('عنوان را فقط یک بار میگذارد', () => {
|
||||||
|
const once = applyPersonGenderTitle({
|
||||||
|
firstName: 'آرش',
|
||||||
|
lastName: 'انتظاری',
|
||||||
|
gender: 'male',
|
||||||
|
showGenderInName: true,
|
||||||
|
});
|
||||||
|
assert.equal(`${once.firstName} ${once.lastName}`, 'جناب آقای آرش انتظاری');
|
||||||
|
|
||||||
|
const again = applyPersonGenderTitle({
|
||||||
|
firstName: once.firstName,
|
||||||
|
lastName: once.lastName,
|
||||||
|
gender: 'male',
|
||||||
|
showGenderInName: true,
|
||||||
|
});
|
||||||
|
assert.equal(`${again.firstName} ${again.lastName}`, 'جناب آقای آرش انتظاری');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ویرایش بخشی از نام، آقای را دوباره به ته نام نمیچسباند', () => {
|
||||||
|
const existing = applyPersonGenderTitle({
|
||||||
|
firstName: 'آرش',
|
||||||
|
lastName: 'انتظاری',
|
||||||
|
gender: 'male',
|
||||||
|
showGenderInName: true,
|
||||||
|
});
|
||||||
|
const bare = splitPersonHonorific(existing.firstName, existing.lastName);
|
||||||
|
const next = applyPersonGenderTitle({
|
||||||
|
firstName: 'محمد',
|
||||||
|
lastName: bare.lastName,
|
||||||
|
gender: 'male',
|
||||||
|
showGenderInName: true,
|
||||||
|
});
|
||||||
|
assert.equal(bare.firstName, 'آرش');
|
||||||
|
assert.equal(bare.lastName, 'انتظاری');
|
||||||
|
assert.equal(`${next.firstName} ${next.lastName}`, 'جناب آقای محمد انتظاری');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, ref } from 'vue';
|
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import {
|
import {
|
||||||
Alert as AAlert,
|
Alert as AAlert,
|
||||||
Button as AButton,
|
Button as AButton,
|
||||||
Card as ACard,
|
Card as ACard,
|
||||||
|
Checkbox as ACheckbox,
|
||||||
Col as ACol,
|
Col as ACol,
|
||||||
Divider as ADivider,
|
Divider as ADivider,
|
||||||
Flex as AFlex,
|
Flex as AFlex,
|
||||||
@@ -34,7 +35,8 @@ import {
|
|||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { CompanyDto, CreatePersonRequest, PersonDto, PersonMood } from '@mento/shared';
|
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 { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
|
||||||
import { formatLocalDateInput, toFaDigits } from '@/lib/jalali';
|
import { formatLocalDateInput, toFaDigits } from '@/lib/jalali';
|
||||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||||
@@ -85,6 +87,8 @@ const companies = ref<CompanyDto[]>([]);
|
|||||||
|
|
||||||
const firstName = ref('');
|
const firstName = ref('');
|
||||||
const lastName = ref('');
|
const lastName = ref('');
|
||||||
|
const gender = ref<PersonGender>('none');
|
||||||
|
const showGenderInName = ref(false);
|
||||||
const email = ref('');
|
const email = ref('');
|
||||||
const phone = ref('');
|
const phone = ref('');
|
||||||
const mobile = ref('');
|
const mobile = ref('');
|
||||||
@@ -125,6 +129,15 @@ const companyOptions = computed(() => [
|
|||||||
...companies.value.map((c) => ({ value: c.id, label: c.name })),
|
...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(() =>
|
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 } },
|
||||||
);
|
);
|
||||||
@@ -249,8 +262,12 @@ function pickFromVcf() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fill(p: PersonDto) {
|
function fill(p: PersonDto) {
|
||||||
firstName.value = p.firstName;
|
const bare = splitPersonHonorific(p.firstName, p.lastName);
|
||||||
lastName.value = 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 ?? '';
|
email.value = p.email ?? '';
|
||||||
phone.value = p.phone ?? '';
|
phone.value = p.phone ?? '';
|
||||||
mobile.value = p.mobile ?? '';
|
mobile.value = p.mobile ?? '';
|
||||||
@@ -330,6 +347,8 @@ function payload(): CreatePersonRequest {
|
|||||||
return {
|
return {
|
||||||
firstName: firstName.value,
|
firstName: firstName.value,
|
||||||
lastName: lastName.value,
|
lastName: lastName.value,
|
||||||
|
gender: gender.value,
|
||||||
|
showGenderInName: gender.value !== 'none' && showGenderInName.value,
|
||||||
email: email.value || undefined,
|
email: email.value || undefined,
|
||||||
phone: phone.value || undefined,
|
phone: phone.value || undefined,
|
||||||
mobile: mobile.value || undefined,
|
mobile: mobile.value || undefined,
|
||||||
@@ -462,6 +481,21 @@ onMounted(load);
|
|||||||
<a-input v-model:value="lastName" />
|
<a-input v-model:value="lastName" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</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-col :xs="24" :md="12">
|
||||||
<a-form-item label="موبایل">
|
<a-form-item label="موبایل">
|
||||||
<a-space-compact block style="width: 100%">
|
<a-space-compact block style="width: 100%">
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import type {
|
|||||||
QuoteDto,
|
QuoteDto,
|
||||||
TicketDto,
|
TicketDto,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
|
import { PERSON_GENDER_LABELS } from '@mento/shared';
|
||||||
import { PERSON_MOOD_LABELS } from '@/lib/person-mood';
|
import { PERSON_MOOD_LABELS } from '@/lib/person-mood';
|
||||||
import { canUseBehavior } from '@/lib/behavior-access';
|
import { canUseBehavior } from '@/lib/behavior-access';
|
||||||
import DiscBadge from '@/components/behavior/DiscBadge.vue';
|
import DiscBadge from '@/components/behavior/DiscBadge.vue';
|
||||||
@@ -167,6 +168,11 @@ const registeredAt = computed(() =>
|
|||||||
const birthDateLabel = computed(() =>
|
const birthDateLabel = computed(() =>
|
||||||
person.value?.birthDate ? formatJalaliNumeric(new Date(person.value.birthDate)) : '—',
|
person.value?.birthDate ? formatJalaliNumeric(new Date(person.value.birthDate)) : '—',
|
||||||
);
|
);
|
||||||
|
const genderLabel = computed(() => {
|
||||||
|
const key = person.value?.gender;
|
||||||
|
if (key === 'male' || key === 'female' || key === 'none') return PERSON_GENDER_LABELS[key];
|
||||||
|
return '—';
|
||||||
|
});
|
||||||
const externalCode = computed(() => {
|
const externalCode = computed(() => {
|
||||||
if (person.value?.sourceRef?.trim()) return person.value.sourceRef.trim();
|
if (person.value?.sourceRef?.trim()) return person.value.sourceRef.trim();
|
||||||
const fields = person.value?.customFields ?? {};
|
const fields = person.value?.customFields ?? {};
|
||||||
@@ -772,6 +778,10 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
|||||||
<dt>سمت</dt>
|
<dt>سمت</dt>
|
||||||
<dd>{{ person.position || '—' }}</dd>
|
<dd>{{ person.position || '—' }}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="fact">
|
||||||
|
<dt>جنسیت</dt>
|
||||||
|
<dd>{{ genderLabel }}</dd>
|
||||||
|
</div>
|
||||||
<div class="fact">
|
<div class="fact">
|
||||||
<dt>تاریخ تولد</dt>
|
<dt>تاریخ تولد</dt>
|
||||||
<dd>{{ birthDateLabel }}</dd>
|
<dd>{{ birthDateLabel }}</dd>
|
||||||
@@ -1102,6 +1112,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
|||||||
<div><dt>تلفن</dt><dd>{{ person.phone ? toFaDigits(person.phone) : '—' }}</dd></div>
|
<div><dt>تلفن</dt><dd>{{ person.phone ? toFaDigits(person.phone) : '—' }}</dd></div>
|
||||||
<div><dt>ایمیل</dt><dd>{{ person.email || '—' }}</dd></div>
|
<div><dt>ایمیل</dt><dd>{{ person.email || '—' }}</dd></div>
|
||||||
<div><dt>سمت</dt><dd>{{ person.position || '—' }}</dd></div>
|
<div><dt>سمت</dt><dd>{{ person.position || '—' }}</dd></div>
|
||||||
|
<div><dt>جنسیت</dt><dd>{{ genderLabel }}</dd></div>
|
||||||
<div><dt>منبع ورود</dt><dd>{{ importSourceLabel }}</dd></div>
|
<div><dt>منبع ورود</dt><dd>{{ importSourceLabel }}</dd></div>
|
||||||
<div><dt>کد مبدأ</dt><dd dir="ltr">{{ externalCode ? toFaDigits(externalCode) : '—' }}</dd></div>
|
<div><dt>کد مبدأ</dt><dd dir="ltr">{{ externalCode ? toFaDigits(externalCode) : '—' }}</dd></div>
|
||||||
<div><dt>نام قبلی</dt><dd>{{ person.previousFullName || '—' }}</dd></div>
|
<div><dt>نام قبلی</dt><dd>{{ person.previousFullName || '—' }}</dd></div>
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
import type { PersonGender } from "./person-name";
|
||||||
|
export type { PersonGender } from "./person-name";
|
||||||
|
export {
|
||||||
|
PERSON_GENDERS,
|
||||||
|
PERSON_GENDER_LABELS,
|
||||||
|
applyPersonGenderTitle,
|
||||||
|
splitPersonHonorific,
|
||||||
|
} from "./person-name";
|
||||||
|
|
||||||
export type UserRole = "owner" | "admin" | "sales" | "support";
|
export type UserRole = "owner" | "admin" | "sales" | "support";
|
||||||
|
|
||||||
export const USER_ROLES: UserRole[] = ["owner", "admin", "sales", "support"];
|
export const USER_ROLES: UserRole[] = ["owner", "admin", "sales", "support"];
|
||||||
@@ -1193,6 +1202,8 @@ export interface PersonDto {
|
|||||||
previousImportSource: PersonImportSource | string | null;
|
previousImportSource: PersonImportSource | string | null;
|
||||||
previousSourceRef: string | null;
|
previousSourceRef: string | null;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
|
gender: PersonGender;
|
||||||
|
showGenderInName: boolean;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
mobile: string | null;
|
mobile: string | null;
|
||||||
@@ -1222,6 +1233,8 @@ export interface PersonDto {
|
|||||||
export interface CreatePersonRequest {
|
export interface CreatePersonRequest {
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
|
gender?: PersonGender;
|
||||||
|
showGenderInName?: boolean;
|
||||||
previousFirstName?: string | null;
|
previousFirstName?: string | null;
|
||||||
previousLastName?: string | null;
|
previousLastName?: string | null;
|
||||||
importSource?: PersonImportSource | string;
|
importSource?: PersonImportSource | string;
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/** مرد، زن، یا هیچکدام (مثلاً فروشگاه) */
|
||||||
|
export type PersonGender = "male" | "female" | "none";
|
||||||
|
|
||||||
|
export const PERSON_GENDERS: PersonGender[] = ["male", "female", "none"];
|
||||||
|
|
||||||
|
export const PERSON_GENDER_LABELS: Record<PersonGender, string> = {
|
||||||
|
male: "مرد",
|
||||||
|
female: "زن",
|
||||||
|
none: "هیچکدام",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MALE_TITLE = "جناب آقای";
|
||||||
|
const FEMALE_TITLE = "سرکار خانم";
|
||||||
|
|
||||||
|
function normalizeFa(value: string) {
|
||||||
|
return value.replace(/ي/g, "ی").replace(/ك/g, "ک").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitWords(value: string) {
|
||||||
|
const parts = normalizeFa(value).split(" ").filter(Boolean);
|
||||||
|
return {
|
||||||
|
firstName: parts[0] ?? "",
|
||||||
|
lastName: parts.slice(1).join(" "),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** عنوان ابتدای نام را برمیدارد تا فیلدهای نام، عنوان را دوباره ذخیره نکنند. */
|
||||||
|
export function splitPersonHonorific(firstName: string, lastName: string): {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
gender: Exclude<PersonGender, "none"> | null;
|
||||||
|
} {
|
||||||
|
let full = normalizeFa(`${firstName} ${lastName}`);
|
||||||
|
let gender: Exclude<PersonGender, "none"> | null = null;
|
||||||
|
// فاصله اختیاری است: «سرکارخانم» و «جناب آقاي» هر دو عنواناند.
|
||||||
|
for (;;) {
|
||||||
|
const male = full.match(/^جناب\s*آقای(?=\s|$)/);
|
||||||
|
const female = full.match(/^سرکار\s*خانم(?=\s|$)/);
|
||||||
|
const hit = male?.[0] ?? female?.[0];
|
||||||
|
if (!hit) break;
|
||||||
|
gender = male ? "male" : "female";
|
||||||
|
full = normalizeFa(full.slice(hit.length));
|
||||||
|
}
|
||||||
|
const plain = splitWords(full);
|
||||||
|
return { ...plain, gender };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نام ذخیرهشده را با عنوان میسازد.
|
||||||
|
* اگر جنسیت یا تیک نفرستاده شود و خود نام عنوان داشته باشد، همان برداشت میشود.
|
||||||
|
*/
|
||||||
|
export function applyPersonGenderTitle(input: {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
gender?: PersonGender | null;
|
||||||
|
showGenderInName?: boolean | null;
|
||||||
|
}): {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
gender: PersonGender;
|
||||||
|
showGenderInName: boolean;
|
||||||
|
} {
|
||||||
|
const bare = splitPersonHonorific(input.firstName, input.lastName);
|
||||||
|
const gender: PersonGender =
|
||||||
|
input.gender === "male" || input.gender === "female" || input.gender === "none"
|
||||||
|
? input.gender
|
||||||
|
: (bare.gender ?? "none");
|
||||||
|
let show = input.showGenderInName;
|
||||||
|
if (typeof show !== "boolean") show = bare.gender != null && gender !== "none";
|
||||||
|
if (gender === "none") show = false;
|
||||||
|
|
||||||
|
const body = [bare.firstName, bare.lastName].filter(Boolean).join(" ");
|
||||||
|
const titled =
|
||||||
|
show && gender === "male"
|
||||||
|
? `${MALE_TITLE} ${body}`.trim()
|
||||||
|
: show && gender === "female"
|
||||||
|
? `${FEMALE_TITLE} ${body}`.trim()
|
||||||
|
: body;
|
||||||
|
const parts = splitWords(titled);
|
||||||
|
return {
|
||||||
|
firstName: parts.firstName || "—",
|
||||||
|
lastName: parts.lastName || "—",
|
||||||
|
gender,
|
||||||
|
showGenderInName: show,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user