From 8b37525f2f6d1ae22a04f8225706eb781f67ac24 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sun, 4 Oct 2026 12:01:21 +0330 Subject: [PATCH] Track person import source and previous names; add source report and edit UX. Co-authored-by: Cursor --- .../migration.sql | 27 ++++ apps/api/prisma/schema.prisma | 9 ++ .../src/contacts/contacts-excel.service.ts | 13 ++ apps/api/src/contacts/dto.ts | 55 +++++++- apps/api/src/contacts/people.controller.ts | 11 +- apps/api/src/contacts/people.service.ts | 61 +++++++- apps/api/src/contacts/person-source.ts | 41 ++++++ .../tahesab/person-link.service.ts | 76 ++++++++-- apps/api/src/leads/leads.service.ts | 2 + .../src/components/PhoneContactsImport.vue | 1 + .../web/src/components/TeamsSettingsPanel.vue | 52 +++++++ apps/web/src/views/ContactsView.vue | 131 ++++++++++++++++-- apps/web/src/views/LeadDetailView.vue | 123 +++++++++++++++- apps/web/src/views/PersonProfileView.vue | 18 +++ packages/shared/src/index.ts | 37 +++++ 15 files changed, 632 insertions(+), 25 deletions(-) create mode 100644 apps/api/prisma/migrations/20261004120000_person_import_source_previous_name/migration.sql create mode 100644 apps/api/src/contacts/person-source.ts 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(() => { }}

+
+
+ + + + + + +
+
+ ذخیره + انصراف +
+
+

اعضا را انتخاب کنید؛ سرپرست همان عضو تیم است.