diff --git a/apps/api/prisma/migrations/20261004120000_person_import_source_previous_name/migration.sql b/apps/api/prisma/migrations/20261004120000_person_import_source_previous_name/migration.sql
new file mode 100644
index 0000000..3836d87
--- /dev/null
+++ b/apps/api/prisma/migrations/20261004120000_person_import_source_previous_name/migration.sql
@@ -0,0 +1,27 @@
+-- AlterTable
+ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "previousFirstName" TEXT;
+ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "previousLastName" TEXT;
+ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "importSource" TEXT NOT NULL DEFAULT 'manual';
+ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "sourceRef" TEXT;
+
+-- CreateIndex
+CREATE INDEX IF NOT EXISTS "Person_organizationId_importSource_idx" ON "Person"("organizationId", "importSource");
+CREATE INDEX IF NOT EXISTS "Person_organizationId_sourceRef_idx" ON "Person"("organizationId", "sourceRef");
+
+-- Backfill from legacy customFields / notes
+UPDATE "Person"
+SET
+ "importSource" = 'didar',
+ "sourceRef" = COALESCE(NULLIF(BTRIM("sourceRef"), ''), NULLIF(BTRIM("customFields"->>'didarId'), ''))
+WHERE COALESCE(BTRIM("customFields"->>'didarId'), '') <> '';
+
+UPDATE "Person"
+SET "importSource" = 'tahesab'
+WHERE "importSource" = 'manual'
+ AND notes ILIKE '%تهحساب%';
+
+UPDATE "Person"
+SET "importSource" = 'excel'
+WHERE "importSource" = 'manual'
+ AND COALESCE(BTRIM("customFields"->>'customerCode'), '') <> ''
+ AND COALESCE(BTRIM("customFields"->>'didarId'), '') = '';
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
index ec4b0e0..71ff819 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -433,6 +433,13 @@ model Person {
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
firstName String
lastName String
+ /// نام قبل از همگامسازی مبدأ (مثلاً تهحساب)
+ previousFirstName String?
+ previousLastName String?
+ /// منبع ورود: manual | tahesab | didar | excel | phone | lead_form | other
+ importSource String @default("manual")
+ /// شناسه در مبدأ (کد دیدار، کد مشتری تهحساب، …)
+ sourceRef String?
email String?
phone String?
mobile String?
@@ -478,6 +485,8 @@ model Person {
@@index([organizationId, email])
@@index([organizationId, mobile])
@@index([organizationId, lastName, firstName])
+ @@index([organizationId, importSource])
+ @@index([organizationId, sourceRef])
@@index([organizationId, loyaltyPoints])
@@index([companyId])
@@index([ownerId])
diff --git a/apps/api/src/contacts/contacts-excel.service.ts b/apps/api/src/contacts/contacts-excel.service.ts
index cbd2260..4a406bf 100644
--- a/apps/api/src/contacts/contacts-excel.service.ts
+++ b/apps/api/src/contacts/contacts-excel.service.ts
@@ -22,6 +22,7 @@ import {
} from '../common/excel-import-session';
import { CompaniesService } from './companies.service';
import { PeopleService } from './people.service';
+import { normalizePersonImportSource } from './person-source';
type ImportChunkOpts = {
sessionId?: string;
@@ -459,6 +460,16 @@ export class ContactsExcelService {
})();
try {
+ const didarId = customFields.didarId?.trim() || '';
+ const customerCode = customFields.customerCode?.trim() || '';
+ const sourceCol = customFields.source?.trim() || '';
+ const importSource = didarId
+ ? 'didar'
+ : sourceCol
+ ? normalizePersonImportSource(sourceCol)
+ : 'excel';
+ const sourceRef = didarId || customerCode || null;
+
await this.people.create(actor, {
firstName: names.firstName,
lastName: names.lastName,
@@ -474,6 +485,8 @@ export class ContactsExcelService {
companyId,
ownerId: resolveOwnerId(actor, ownerId ?? null),
customFields,
+ importSource,
+ sourceRef,
registeredAt: registeredAt?.toISOString() ?? null,
});
result.created += 1;
diff --git a/apps/api/src/contacts/dto.ts b/apps/api/src/contacts/dto.ts
index fa2661d..8fb3f04 100644
--- a/apps/api/src/contacts/dto.ts
+++ b/apps/api/src/contacts/dto.ts
@@ -13,7 +13,7 @@ import {
MinLength,
ValidateIf,
} from 'class-validator';
-import { PERSON_MOODS } from '@mento/shared';
+import { PERSON_IMPORT_SOURCES, PERSON_MOODS } from '@mento/shared';
export class ListQueryDto {
@IsOptional()
@@ -40,6 +40,12 @@ export class ListQueryDto {
@IsOptional()
@IsIn(['name', 'newest'])
sort?: 'name' | 'newest';
+
+ /** فیلتر منبع ورود شخص */
+ @IsOptional()
+ @IsString()
+ @MaxLength(40)
+ importSource?: string;
}
export class CreateCompanyDto {
@@ -156,6 +162,30 @@ export class CreatePersonDto {
@MaxLength(80)
lastName?: string;
+ @IsOptional()
+ @ValidateIf((_, v) => v !== '' && v != null)
+ @IsString()
+ @MaxLength(80)
+ previousFirstName?: string | null;
+
+ @IsOptional()
+ @ValidateIf((_, v) => v !== '' && v != null)
+ @IsString()
+ @MaxLength(80)
+ previousLastName?: string | null;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(40)
+ @IsIn([...PERSON_IMPORT_SOURCES])
+ importSource?: (typeof PERSON_IMPORT_SOURCES)[number];
+
+ @IsOptional()
+ @ValidateIf((_, v) => v !== '' && v != null)
+ @IsString()
+ @MaxLength(120)
+ sourceRef?: string | null;
+
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsEmail()
@@ -241,6 +271,29 @@ export class UpdatePersonDto {
@MaxLength(80)
lastName?: string;
+ @IsOptional()
+ @ValidateIf((_, v) => v !== '' && v != null)
+ @IsString()
+ @MaxLength(80)
+ previousFirstName?: string | null;
+
+ @IsOptional()
+ @ValidateIf((_, v) => v !== '' && v != null)
+ @IsString()
+ @MaxLength(80)
+ previousLastName?: string | null;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(40)
+ importSource?: string;
+
+ @IsOptional()
+ @ValidateIf((_, v) => v !== '' && v != null)
+ @IsString()
+ @MaxLength(120)
+ sourceRef?: string | null;
+
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsEmail()
diff --git a/apps/api/src/contacts/people.controller.ts b/apps/api/src/contacts/people.controller.ts
index 5937514..b9c3b97 100644
--- a/apps/api/src/contacts/people.controller.ts
+++ b/apps/api/src/contacts/people.controller.ts
@@ -48,10 +48,19 @@ export class PeopleController {
query.q,
query.page ?? 1,
query.limit ?? 20,
- { mood: query.mood, sort: query.sort },
+ {
+ mood: query.mood,
+ sort: query.sort,
+ importSource: query.importSource,
+ },
);
}
+ @Get('source-stats')
+ sourceStats(@CurrentUser() user: Actor) {
+ return this.peopleService.sourceStats(user);
+ }
+
@Get('export')
async export(
@CurrentUser() user: Actor,
diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts
index 177dd36..d51a376 100644
--- a/apps/api/src/contacts/people.service.ts
+++ b/apps/api/src/contacts/people.service.ts
@@ -7,6 +7,7 @@ import type {
CreatePersonRequest,
PaginatedResponse,
PersonDto,
+ PersonImportSourceStatDto,
PersonMood,
UpdatePersonRequest,
} from '@mento/shared';
@@ -31,6 +32,10 @@ import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { WebhooksService } from '../public-api/webhooks.service';
import { normalizeTags } from '../common/tags';
+import {
+ normalizePersonImportSource,
+ personImportSourceLabel,
+} from './person-source';
const personInclude = {
company: { select: { id: true, name: true } },
@@ -44,6 +49,10 @@ type PersonRow = {
id: string;
firstName: string;
lastName: string;
+ previousFirstName: string | null;
+ previousLastName: string | null;
+ importSource: string;
+ sourceRef: string | null;
email: string | null;
phone: string | null;
mobile: string | null;
@@ -85,20 +94,31 @@ export class PeopleService {
q?: string,
page = 1,
limit = 20,
- opts?: { mood?: PersonMood; sort?: 'name' | 'newest' },
+ opts?: {
+ mood?: PersonMood;
+ sort?: 'name' | 'newest';
+ importSource?: string;
+ },
): Promise> {
+ const sourceFilter = opts?.importSource?.trim()
+ ? normalizePersonImportSource(opts.importSource)
+ : undefined;
const where: Prisma.PersonWhereInput = {
organizationId: actor.organizationId,
...ownerWhere(actor),
...(opts?.mood ? { mood: opts.mood } : {}),
+ ...(sourceFilter ? { importSource: sourceFilter } : {}),
...(q?.trim()
? {
OR: [
{ firstName: { contains: q.trim(), mode: 'insensitive' } },
{ lastName: { contains: q.trim(), mode: 'insensitive' } },
+ { previousFirstName: { contains: q.trim(), mode: 'insensitive' } },
+ { previousLastName: { contains: q.trim(), mode: 'insensitive' } },
{ email: { contains: q.trim(), mode: 'insensitive' } },
{ mobile: { contains: q.trim() } },
{ phone: { contains: q.trim() } },
+ { sourceRef: { contains: q.trim() } },
{ company: { name: { contains: q.trim(), mode: 'insensitive' } } },
],
}
@@ -127,6 +147,24 @@ export class PeopleService {
};
}
+ async sourceStats(actor: Actor): Promise {
+ const rows = await this.prisma.person.groupBy({
+ by: ['importSource'],
+ where: {
+ organizationId: actor.organizationId,
+ ...ownerWhere(actor),
+ },
+ _count: { _all: true },
+ });
+ return rows
+ .map((row) => ({
+ source: row.importSource || 'manual',
+ label: personImportSourceLabel(row.importSource || 'manual'),
+ count: row._count._all,
+ }))
+ .sort((a, b) => b.count - a.count);
+ }
+
async get(actor: Actor, id: string): Promise {
const row = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
@@ -147,6 +185,10 @@ export class PeopleService {
organizationId: actor.organizationId,
firstName: (dto.firstName ?? '').trim(),
lastName: (dto.lastName ?? '').trim(),
+ previousFirstName: emptyToNull(dto.previousFirstName),
+ previousLastName: emptyToNull(dto.previousLastName),
+ importSource: normalizePersonImportSource(dto.importSource),
+ sourceRef: emptyToNull(dto.sourceRef),
email: normalizeEmail(dto.email),
phone: normalizePhone(dto.phone),
mobile: normalizePhone(dto.mobile),
@@ -207,6 +249,16 @@ export class PeopleService {
const data: Prisma.PersonUncheckedUpdateInput = {};
if (dto.firstName !== undefined) data.firstName = dto.firstName.trim();
if (dto.lastName !== undefined) data.lastName = dto.lastName.trim();
+ if ('previousFirstName' in dto) {
+ data.previousFirstName = emptyToNull(dto.previousFirstName);
+ }
+ if ('previousLastName' in dto) {
+ data.previousLastName = emptyToNull(dto.previousLastName);
+ }
+ if (dto.importSource !== undefined) {
+ data.importSource = normalizePersonImportSource(dto.importSource);
+ }
+ if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef);
if ('email' in dto) data.email = normalizeEmail(dto.email);
if ('phone' in dto) data.phone = normalizePhone(dto.phone);
if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile);
@@ -297,10 +349,17 @@ export class PeopleService {
}
private toDto(row: PersonRow): PersonDto {
+ const previousFullName = `${row.previousFirstName ?? ''} ${row.previousLastName ?? ''}`.trim();
+ const importSource = row.importSource || 'manual';
return {
id: row.id,
firstName: row.firstName,
lastName: row.lastName,
+ previousFirstName: row.previousFirstName,
+ previousLastName: row.previousLastName,
+ previousFullName: previousFullName || null,
+ importSource,
+ sourceRef: row.sourceRef,
fullName: `${row.firstName} ${row.lastName}`.trim(),
email: row.email,
phone: row.phone,
diff --git a/apps/api/src/contacts/person-source.ts b/apps/api/src/contacts/person-source.ts
new file mode 100644
index 0000000..1aa0ebd
--- /dev/null
+++ b/apps/api/src/contacts/person-source.ts
@@ -0,0 +1,41 @@
+import {
+ PERSON_IMPORT_SOURCE_LABELS,
+ PERSON_IMPORT_SOURCES,
+ type PersonImportSource,
+} from '@mento/shared';
+
+export function normalizePersonImportSource(
+ raw?: string | null,
+): PersonImportSource {
+ const v = String(raw ?? '')
+ .trim()
+ .toLowerCase();
+ if (!v) return 'manual';
+ if ((PERSON_IMPORT_SOURCES as readonly string[]).includes(v)) {
+ return v as PersonImportSource;
+ }
+ if (/دیدار|didar/.test(v)) return 'didar';
+ if (/ته.?حساب|tahesab|accounting/.test(v)) return 'tahesab';
+ if (/اکسل|excel|xlsx|csv/.test(v)) return 'excel';
+ if (/گوشی|phone|مخاطب|vcard|contact/.test(v)) return 'phone';
+ if (/لید|lead|فرم/.test(v)) return 'lead_form';
+ return 'other';
+}
+
+export function personImportSourceLabel(source: string): string {
+ if (source in PERSON_IMPORT_SOURCE_LABELS) {
+ return PERSON_IMPORT_SOURCE_LABELS[source as PersonImportSource];
+ }
+ return source || PERSON_IMPORT_SOURCE_LABELS.manual;
+}
+
+export function namesDiffer(
+ aFirst: string,
+ aLast: string,
+ bFirst: string,
+ bLast: string,
+): boolean {
+ const a = `${aFirst} ${aLast}`.trim().replace(/\s+/g, ' ');
+ const b = `${bFirst} ${bLast}`.trim().replace(/\s+/g, ' ');
+ return Boolean(a && b && a !== b);
+}
diff --git a/apps/api/src/integrations/tahesab/person-link.service.ts b/apps/api/src/integrations/tahesab/person-link.service.ts
index a27e06c..0fc3bf8 100644
--- a/apps/api/src/integrations/tahesab/person-link.service.ts
+++ b/apps/api/src/integrations/tahesab/person-link.service.ts
@@ -493,22 +493,82 @@ export class TahesabPersonLinkService {
const parts = name.trim().split(/\s+/);
const firstName = parts[0] || name;
const lastName = parts.slice(1).join(' ') || '—';
+ const mobile = tel || null;
+ const sourceRef = String(dto.moshtariCode);
let personId = dto.personId;
if (personId) {
- await this.getPerson(organizationId, personId);
- } else {
- const created = await this.prisma.person.create({
+ const existing = await this.getPerson(organizationId, personId);
+ const prevFirst = existing.firstName;
+ const prevLast = existing.lastName;
+ const nameChanged =
+ `${prevFirst} ${prevLast}`.trim() !== `${firstName} ${lastName}`.trim();
+ await this.prisma.person.update({
+ where: { id: personId },
data: {
- organizationId,
firstName,
lastName,
- mobile: tel || null,
- phone: tel || null,
- notes: `واردشده از تهحساب (کد ${dto.moshtariCode})`,
+ ...(nameChanged
+ ? {
+ previousFirstName: prevFirst || null,
+ previousLastName: prevLast || null,
+ }
+ : {}),
+ importSource: 'tahesab',
+ sourceRef,
+ ...(mobile
+ ? {
+ mobile: existing.mobile || mobile,
+ phone: existing.phone || mobile,
+ }
+ : {}),
},
});
- personId = created.id;
+ } else {
+ // اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام تهحساب جایگزین
+ const byMobile = mobile
+ ? await this.prisma.person.findFirst({
+ where: {
+ organizationId,
+ OR: [{ mobile }, { phone: mobile }],
+ },
+ })
+ : null;
+ if (byMobile) {
+ const nameChanged =
+ `${byMobile.firstName} ${byMobile.lastName}`.trim() !==
+ `${firstName} ${lastName}`.trim();
+ await this.prisma.person.update({
+ where: { id: byMobile.id },
+ data: {
+ firstName,
+ lastName,
+ ...(nameChanged
+ ? {
+ previousFirstName: byMobile.firstName || null,
+ previousLastName: byMobile.lastName || null,
+ }
+ : {}),
+ importSource: 'tahesab',
+ sourceRef,
+ },
+ });
+ personId = byMobile.id;
+ } else {
+ const created = await this.prisma.person.create({
+ data: {
+ organizationId,
+ firstName,
+ lastName,
+ mobile,
+ phone: mobile,
+ notes: `واردشده از تهحساب (کد ${dto.moshtariCode})`,
+ importSource: 'tahesab',
+ sourceRef,
+ },
+ });
+ personId = created.id;
+ }
}
const link = await this.prisma.personAccountingLink.upsert({
diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts
index d9622db..095480d 100644
--- a/apps/api/src/leads/leads.service.ts
+++ b/apps/api/src/leads/leads.service.ts
@@ -756,6 +756,8 @@ export class LeadsService {
.join('\n'),
ownerId: lead.assigneeId ?? actor.userId,
createdAt: lead.createdAt,
+ importSource: 'lead_form',
+ sourceRef: lead.id,
},
});
createdPerson = true;
diff --git a/apps/web/src/components/PhoneContactsImport.vue b/apps/web/src/components/PhoneContactsImport.vue
index aaee99e..baf6472 100644
--- a/apps/web/src/components/PhoneContactsImport.vue
+++ b/apps/web/src/components/PhoneContactsImport.vue
@@ -197,6 +197,7 @@ async function importSelected() {
mobile: row.mobile,
phone: row.phone,
email: row.email,
+ importSource: 'phone',
};
await api.post('/people', body);
}
diff --git a/apps/web/src/components/TeamsSettingsPanel.vue b/apps/web/src/components/TeamsSettingsPanel.vue
index 6dad466..a587db5 100644
--- a/apps/web/src/components/TeamsSettingsPanel.vue
+++ b/apps/web/src/components/TeamsSettingsPanel.vue
@@ -33,6 +33,9 @@ const teams = ref([]);
const newName = ref('');
const newDesc = ref('');
const editingId = ref(null);
+const editingMetaId = ref(null);
+const editName = ref('');
+const editDesc = ref('');
const memberDraft = ref>({});
const editableUsers = computed(() =>
@@ -83,6 +86,7 @@ async function createTeam() {
function startEdit(team: TeamDto) {
editingId.value = team.id;
+ editingMetaId.value = null;
const draft: Record = {};
for (const m of team.members) {
draft[m.userId] = m.role;
@@ -90,6 +94,38 @@ function startEdit(team: TeamDto) {
memberDraft.value = draft;
}
+function startMetaEdit(team: TeamDto) {
+ editingMetaId.value = team.id;
+ editingId.value = null;
+ editName.value = team.name;
+ editDesc.value = team.description || '';
+}
+
+async function saveMeta() {
+ if (!editingMetaId.value) return;
+ if (!editName.value.trim()) {
+ error.value = 'نام تیم را وارد کنید.';
+ return;
+ }
+ saving.value = true;
+ error.value = '';
+ try {
+ await api.patch(`/teams/${editingMetaId.value}`, {
+ name: editName.value.trim(),
+ description: editDesc.value.trim(),
+ });
+ ok.value = 'اطلاعات تیم ذخیره شد.';
+ editingMetaId.value = null;
+ await load();
+ } catch (e) {
+ error.value = axios.isAxiosError(e)
+ ? String((e.response?.data as { message?: string })?.message ?? 'ذخیره تیم ناموفق بود.')
+ : 'ذخیره تیم ناموفق بود.';
+ } finally {
+ saving.value = false;
+ }
+}
+
function toggleMember(userId: string) {
if (memberDraft.value[userId]) {
const next = { ...memberDraft.value };
@@ -194,6 +230,7 @@ onMounted(() => {
+ ویرایش
اعضا
حذف
@@ -208,6 +245,21 @@ onMounted(() => {
}}
+
+
+
+
+
+
+
+
+
+
+ ذخیره
+ انصراف
+
+
+
اعضا را انتخاب کنید؛ سرپرست همان عضو تیم است.