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:
2026-10-29 18:46:10 +03:30
co-authored by Cursor
parent 0d5e1034e5
commit b9bb2ff9fa
11 changed files with 350 additions and 14 deletions
+13
View File
@@ -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 const USER_ROLES: UserRole[] = ["owner", "admin", "sales", "support"];
@@ -1193,6 +1202,8 @@ export interface PersonDto {
previousImportSource: PersonImportSource | string | null;
previousSourceRef: string | null;
fullName: string;
gender: PersonGender;
showGenderInName: boolean;
email: string | null;
phone: string | null;
mobile: string | null;
@@ -1222,6 +1233,8 @@ export interface PersonDto {
export interface CreatePersonRequest {
firstName?: string;
lastName?: string;
gender?: PersonGender;
showGenderInName?: boolean;
previousFirstName?: string | null;
previousLastName?: string | null;
importSource?: PersonImportSource | string;
+86
View File
@@ -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,
};
}