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:
2026-10-31 12:49:42 +03:30
co-authored by Cursor
parent cbc2e4ee8a
commit d7b5085096
12 changed files with 334 additions and 19 deletions
+58 -4
View File
@@ -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>
+47 -3
View File
@@ -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>