Track person import source and previous names; add source report and edit UX.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+27
@@ -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'), '') = '';
|
||||||
@@ -433,6 +433,13 @@ 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
|
||||||
|
/// نام قبل از همگامسازی مبدأ (مثلاً تهحساب)
|
||||||
|
previousFirstName String?
|
||||||
|
previousLastName String?
|
||||||
|
/// منبع ورود: manual | tahesab | didar | excel | phone | lead_form | other
|
||||||
|
importSource String @default("manual")
|
||||||
|
/// شناسه در مبدأ (کد دیدار، کد مشتری تهحساب، …)
|
||||||
|
sourceRef String?
|
||||||
email String?
|
email String?
|
||||||
phone String?
|
phone String?
|
||||||
mobile String?
|
mobile String?
|
||||||
@@ -478,6 +485,8 @@ model Person {
|
|||||||
@@index([organizationId, email])
|
@@index([organizationId, email])
|
||||||
@@index([organizationId, mobile])
|
@@index([organizationId, mobile])
|
||||||
@@index([organizationId, lastName, firstName])
|
@@index([organizationId, lastName, firstName])
|
||||||
|
@@index([organizationId, importSource])
|
||||||
|
@@index([organizationId, sourceRef])
|
||||||
@@index([organizationId, loyaltyPoints])
|
@@index([organizationId, loyaltyPoints])
|
||||||
@@index([companyId])
|
@@index([companyId])
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from '../common/excel-import-session';
|
} from '../common/excel-import-session';
|
||||||
import { CompaniesService } from './companies.service';
|
import { CompaniesService } from './companies.service';
|
||||||
import { PeopleService } from './people.service';
|
import { PeopleService } from './people.service';
|
||||||
|
import { normalizePersonImportSource } from './person-source';
|
||||||
|
|
||||||
type ImportChunkOpts = {
|
type ImportChunkOpts = {
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
@@ -459,6 +460,16 @@ export class ContactsExcelService {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
try {
|
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, {
|
await this.people.create(actor, {
|
||||||
firstName: names.firstName,
|
firstName: names.firstName,
|
||||||
lastName: names.lastName,
|
lastName: names.lastName,
|
||||||
@@ -474,6 +485,8 @@ export class ContactsExcelService {
|
|||||||
companyId,
|
companyId,
|
||||||
ownerId: resolveOwnerId(actor, ownerId ?? null),
|
ownerId: resolveOwnerId(actor, ownerId ?? null),
|
||||||
customFields,
|
customFields,
|
||||||
|
importSource,
|
||||||
|
sourceRef,
|
||||||
registeredAt: registeredAt?.toISOString() ?? null,
|
registeredAt: registeredAt?.toISOString() ?? null,
|
||||||
});
|
});
|
||||||
result.created += 1;
|
result.created += 1;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
MinLength,
|
MinLength,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { PERSON_MOODS } from '@mento/shared';
|
import { PERSON_IMPORT_SOURCES, PERSON_MOODS } from '@mento/shared';
|
||||||
|
|
||||||
export class ListQueryDto {
|
export class ListQueryDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -40,6 +40,12 @@ export class ListQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['name', 'newest'])
|
@IsIn(['name', 'newest'])
|
||||||
sort?: 'name' | 'newest';
|
sort?: 'name' | 'newest';
|
||||||
|
|
||||||
|
/** فیلتر منبع ورود شخص */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(40)
|
||||||
|
importSource?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateCompanyDto {
|
export class CreateCompanyDto {
|
||||||
@@ -156,6 +162,30 @@ export class CreatePersonDto {
|
|||||||
@MaxLength(80)
|
@MaxLength(80)
|
||||||
lastName?: string;
|
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()
|
@IsOptional()
|
||||||
@ValidateIf((_, v) => v !== '' && v != null)
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
@@ -241,6 +271,29 @@ export class UpdatePersonDto {
|
|||||||
@MaxLength(80)
|
@MaxLength(80)
|
||||||
lastName?: string;
|
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()
|
@IsOptional()
|
||||||
@ValidateIf((_, v) => v !== '' && v != null)
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
|
|||||||
@@ -48,10 +48,19 @@ export class PeopleController {
|
|||||||
query.q,
|
query.q,
|
||||||
query.page ?? 1,
|
query.page ?? 1,
|
||||||
query.limit ?? 20,
|
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')
|
@Get('export')
|
||||||
async export(
|
async export(
|
||||||
@CurrentUser() user: Actor,
|
@CurrentUser() user: Actor,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
CreatePersonRequest,
|
CreatePersonRequest,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
PersonDto,
|
PersonDto,
|
||||||
|
PersonImportSourceStatDto,
|
||||||
PersonMood,
|
PersonMood,
|
||||||
UpdatePersonRequest,
|
UpdatePersonRequest,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
@@ -31,6 +32,10 @@ import { AutomationsService } from '../automations/automations.service';
|
|||||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||||
import { WebhooksService } from '../public-api/webhooks.service';
|
import { WebhooksService } from '../public-api/webhooks.service';
|
||||||
import { normalizeTags } from '../common/tags';
|
import { normalizeTags } from '../common/tags';
|
||||||
|
import {
|
||||||
|
normalizePersonImportSource,
|
||||||
|
personImportSourceLabel,
|
||||||
|
} from './person-source';
|
||||||
|
|
||||||
const personInclude = {
|
const personInclude = {
|
||||||
company: { select: { id: true, name: true } },
|
company: { select: { id: true, name: true } },
|
||||||
@@ -44,6 +49,10 @@ type PersonRow = {
|
|||||||
id: string;
|
id: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
|
previousFirstName: string | null;
|
||||||
|
previousLastName: string | null;
|
||||||
|
importSource: string;
|
||||||
|
sourceRef: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
mobile: string | null;
|
mobile: string | null;
|
||||||
@@ -85,20 +94,31 @@ export class PeopleService {
|
|||||||
q?: string,
|
q?: string,
|
||||||
page = 1,
|
page = 1,
|
||||||
limit = 20,
|
limit = 20,
|
||||||
opts?: { mood?: PersonMood; sort?: 'name' | 'newest' },
|
opts?: {
|
||||||
|
mood?: PersonMood;
|
||||||
|
sort?: 'name' | 'newest';
|
||||||
|
importSource?: string;
|
||||||
|
},
|
||||||
): Promise<PaginatedResponse<PersonDto>> {
|
): Promise<PaginatedResponse<PersonDto>> {
|
||||||
|
const sourceFilter = opts?.importSource?.trim()
|
||||||
|
? normalizePersonImportSource(opts.importSource)
|
||||||
|
: undefined;
|
||||||
const where: Prisma.PersonWhereInput = {
|
const where: Prisma.PersonWhereInput = {
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
...ownerWhere(actor),
|
...ownerWhere(actor),
|
||||||
...(opts?.mood ? { mood: opts.mood } : {}),
|
...(opts?.mood ? { mood: opts.mood } : {}),
|
||||||
|
...(sourceFilter ? { importSource: sourceFilter } : {}),
|
||||||
...(q?.trim()
|
...(q?.trim()
|
||||||
? {
|
? {
|
||||||
OR: [
|
OR: [
|
||||||
{ firstName: { contains: q.trim(), mode: 'insensitive' } },
|
{ firstName: { contains: q.trim(), mode: 'insensitive' } },
|
||||||
{ lastName: { 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' } },
|
{ email: { contains: q.trim(), mode: 'insensitive' } },
|
||||||
{ mobile: { contains: q.trim() } },
|
{ mobile: { contains: q.trim() } },
|
||||||
{ phone: { contains: q.trim() } },
|
{ phone: { contains: q.trim() } },
|
||||||
|
{ sourceRef: { contains: q.trim() } },
|
||||||
{ company: { name: { contains: q.trim(), mode: 'insensitive' } } },
|
{ company: { name: { contains: q.trim(), mode: 'insensitive' } } },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -127,6 +147,24 @@ export class PeopleService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sourceStats(actor: Actor): Promise<PersonImportSourceStatDto[]> {
|
||||||
|
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<PersonDto> {
|
async get(actor: Actor, id: string): Promise<PersonDto> {
|
||||||
const row = await this.prisma.person.findFirst({
|
const row = await this.prisma.person.findFirst({
|
||||||
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
|
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
|
||||||
@@ -147,6 +185,10 @@ export class PeopleService {
|
|||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
firstName: (dto.firstName ?? '').trim(),
|
firstName: (dto.firstName ?? '').trim(),
|
||||||
lastName: (dto.lastName ?? '').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),
|
email: normalizeEmail(dto.email),
|
||||||
phone: normalizePhone(dto.phone),
|
phone: normalizePhone(dto.phone),
|
||||||
mobile: normalizePhone(dto.mobile),
|
mobile: normalizePhone(dto.mobile),
|
||||||
@@ -207,6 +249,16 @@ export class PeopleService {
|
|||||||
const data: Prisma.PersonUncheckedUpdateInput = {};
|
const data: Prisma.PersonUncheckedUpdateInput = {};
|
||||||
if (dto.firstName !== undefined) data.firstName = dto.firstName.trim();
|
if (dto.firstName !== undefined) data.firstName = dto.firstName.trim();
|
||||||
if (dto.lastName !== undefined) data.lastName = dto.lastName.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 ('email' in dto) data.email = normalizeEmail(dto.email);
|
||||||
if ('phone' in dto) data.phone = normalizePhone(dto.phone);
|
if ('phone' in dto) data.phone = normalizePhone(dto.phone);
|
||||||
if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile);
|
if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile);
|
||||||
@@ -297,10 +349,17 @@ export class PeopleService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private toDto(row: PersonRow): PersonDto {
|
private toDto(row: PersonRow): PersonDto {
|
||||||
|
const previousFullName = `${row.previousFirstName ?? ''} ${row.previousLastName ?? ''}`.trim();
|
||||||
|
const importSource = row.importSource || 'manual';
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
firstName: row.firstName,
|
firstName: row.firstName,
|
||||||
lastName: row.lastName,
|
lastName: row.lastName,
|
||||||
|
previousFirstName: row.previousFirstName,
|
||||||
|
previousLastName: row.previousLastName,
|
||||||
|
previousFullName: previousFullName || null,
|
||||||
|
importSource,
|
||||||
|
sourceRef: row.sourceRef,
|
||||||
fullName: `${row.firstName} ${row.lastName}`.trim(),
|
fullName: `${row.firstName} ${row.lastName}`.trim(),
|
||||||
email: row.email,
|
email: row.email,
|
||||||
phone: row.phone,
|
phone: row.phone,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -493,22 +493,82 @@ export class TahesabPersonLinkService {
|
|||||||
const parts = name.trim().split(/\s+/);
|
const parts = name.trim().split(/\s+/);
|
||||||
const firstName = parts[0] || name;
|
const firstName = parts[0] || name;
|
||||||
const lastName = parts.slice(1).join(' ') || '—';
|
const lastName = parts.slice(1).join(' ') || '—';
|
||||||
|
const mobile = tel || null;
|
||||||
|
const sourceRef = String(dto.moshtariCode);
|
||||||
|
|
||||||
let personId = dto.personId;
|
let personId = dto.personId;
|
||||||
if (personId) {
|
if (personId) {
|
||||||
await this.getPerson(organizationId, personId);
|
const existing = await this.getPerson(organizationId, personId);
|
||||||
} else {
|
const prevFirst = existing.firstName;
|
||||||
const created = await this.prisma.person.create({
|
const prevLast = existing.lastName;
|
||||||
|
const nameChanged =
|
||||||
|
`${prevFirst} ${prevLast}`.trim() !== `${firstName} ${lastName}`.trim();
|
||||||
|
await this.prisma.person.update({
|
||||||
|
where: { id: personId },
|
||||||
data: {
|
data: {
|
||||||
organizationId,
|
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
mobile: tel || null,
|
...(nameChanged
|
||||||
phone: tel || null,
|
? {
|
||||||
notes: `واردشده از تهحساب (کد ${dto.moshtariCode})`,
|
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({
|
const link = await this.prisma.personAccountingLink.upsert({
|
||||||
|
|||||||
@@ -756,6 +756,8 @@ export class LeadsService {
|
|||||||
.join('\n'),
|
.join('\n'),
|
||||||
ownerId: lead.assigneeId ?? actor.userId,
|
ownerId: lead.assigneeId ?? actor.userId,
|
||||||
createdAt: lead.createdAt,
|
createdAt: lead.createdAt,
|
||||||
|
importSource: 'lead_form',
|
||||||
|
sourceRef: lead.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
createdPerson = true;
|
createdPerson = true;
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ async function importSelected() {
|
|||||||
mobile: row.mobile,
|
mobile: row.mobile,
|
||||||
phone: row.phone,
|
phone: row.phone,
|
||||||
email: row.email,
|
email: row.email,
|
||||||
|
importSource: 'phone',
|
||||||
};
|
};
|
||||||
await api.post<PersonDto>('/people', body);
|
await api.post<PersonDto>('/people', body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ const teams = ref<TeamDto[]>([]);
|
|||||||
const newName = ref('');
|
const newName = ref('');
|
||||||
const newDesc = ref('');
|
const newDesc = ref('');
|
||||||
const editingId = ref<string | null>(null);
|
const editingId = ref<string | null>(null);
|
||||||
|
const editingMetaId = ref<string | null>(null);
|
||||||
|
const editName = ref('');
|
||||||
|
const editDesc = ref('');
|
||||||
const memberDraft = ref<Record<string, TeamMemberRole>>({});
|
const memberDraft = ref<Record<string, TeamMemberRole>>({});
|
||||||
|
|
||||||
const editableUsers = computed(() =>
|
const editableUsers = computed(() =>
|
||||||
@@ -83,6 +86,7 @@ async function createTeam() {
|
|||||||
|
|
||||||
function startEdit(team: TeamDto) {
|
function startEdit(team: TeamDto) {
|
||||||
editingId.value = team.id;
|
editingId.value = team.id;
|
||||||
|
editingMetaId.value = null;
|
||||||
const draft: Record<string, TeamMemberRole> = {};
|
const draft: Record<string, TeamMemberRole> = {};
|
||||||
for (const m of team.members) {
|
for (const m of team.members) {
|
||||||
draft[m.userId] = m.role;
|
draft[m.userId] = m.role;
|
||||||
@@ -90,6 +94,38 @@ function startEdit(team: TeamDto) {
|
|||||||
memberDraft.value = draft;
|
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) {
|
function toggleMember(userId: string) {
|
||||||
if (memberDraft.value[userId]) {
|
if (memberDraft.value[userId]) {
|
||||||
const next = { ...memberDraft.value };
|
const next = { ...memberDraft.value };
|
||||||
@@ -194,6 +230,7 @@ onMounted(() => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="canEdit" class="actions">
|
<div v-if="canEdit" class="actions">
|
||||||
|
<UiButton variant="secondary" @click="startMetaEdit(t)">ویرایش</UiButton>
|
||||||
<UiButton variant="secondary" @click="startEdit(t)">اعضا</UiButton>
|
<UiButton variant="secondary" @click="startEdit(t)">اعضا</UiButton>
|
||||||
<UiButton variant="secondary" @click="removeTeam(t.id)">حذف</UiButton>
|
<UiButton variant="secondary" @click="removeTeam(t.id)">حذف</UiButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,6 +245,21 @@ onMounted(() => {
|
|||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div v-if="editingMetaId === t.id" class="editor">
|
||||||
|
<div class="row">
|
||||||
|
<UiField label="نام تیم">
|
||||||
|
<UiInput v-model="editName" />
|
||||||
|
</UiField>
|
||||||
|
<UiField label="توضیح">
|
||||||
|
<UiInput v-model="editDesc" />
|
||||||
|
</UiField>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<UiButton :loading="saving" @click="saveMeta">ذخیره</UiButton>
|
||||||
|
<UiButton variant="secondary" @click="editingMetaId = null">انصراف</UiButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="editingId === t.id" class="editor">
|
<div v-if="editingId === t.id" class="editor">
|
||||||
<p class="hint">اعضا را انتخاب کنید؛ سرپرست همان عضو تیم است.</p>
|
<p class="hint">اعضا را انتخاب کنید؛ سرپرست همان عضو تیم است.</p>
|
||||||
<label v-for="u in users.filter((x) => x.isActive !== false)" :key="u.id" class="person">
|
<label v-for="u in users.filter((x) => x.isActive !== false)" :key="u.id" class="person">
|
||||||
|
|||||||
@@ -25,9 +25,15 @@ import type {
|
|||||||
ImportContactsResult,
|
ImportContactsResult,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
PersonDto,
|
PersonDto,
|
||||||
|
PersonImportSource,
|
||||||
|
PersonImportSourceStatDto,
|
||||||
PersonMood,
|
PersonMood,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { brand } from '@mento/shared';
|
import {
|
||||||
|
brand,
|
||||||
|
PERSON_IMPORT_SOURCE_LABELS,
|
||||||
|
PERSON_IMPORT_SOURCES,
|
||||||
|
} from '@mento/shared';
|
||||||
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
|
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
|
||||||
|
|
||||||
type Tab = 'people' | 'companies';
|
type Tab = 'people' | 'companies';
|
||||||
@@ -50,6 +56,12 @@ const moodFilter = computed<PersonMood | 'all'>(() => {
|
|||||||
const value = typeof route.query.mood === 'string' ? route.query.mood : 'all';
|
const value = typeof route.query.mood === 'string' ? route.query.mood : 'all';
|
||||||
return PERSON_MOODS.includes(value as PersonMood) ? (value as PersonMood) : 'all';
|
return PERSON_MOODS.includes(value as PersonMood) ? (value as PersonMood) : 'all';
|
||||||
});
|
});
|
||||||
|
const sourceFilter = computed<PersonImportSource | 'all'>(() => {
|
||||||
|
const value = typeof route.query.source === 'string' ? route.query.source : 'all';
|
||||||
|
return PERSON_IMPORT_SOURCES.includes(value as PersonImportSource)
|
||||||
|
? (value as PersonImportSource)
|
||||||
|
: 'all';
|
||||||
|
});
|
||||||
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
|
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
@@ -59,6 +71,7 @@ const error = ref('');
|
|||||||
const importMsg = ref('');
|
const importMsg = ref('');
|
||||||
const people = ref<PaginatedResponse<PersonDto> | null>(null);
|
const people = ref<PaginatedResponse<PersonDto> | null>(null);
|
||||||
const companies = ref<PaginatedResponse<CompanyDto> | null>(null);
|
const companies = ref<PaginatedResponse<CompanyDto> | null>(null);
|
||||||
|
const sourceStats = ref<PersonImportSourceStatDto[]>([]);
|
||||||
const menuId = ref<string | null>(null);
|
const menuId = ref<string | null>(null);
|
||||||
const sortOpen = ref(false);
|
const sortOpen = ref(false);
|
||||||
const filterOpen = ref(false);
|
const filterOpen = ref(false);
|
||||||
@@ -73,6 +86,7 @@ const peopleSavedFilters = computed(() => ({
|
|||||||
q: q.value.trim() || undefined,
|
q: q.value.trim() || undefined,
|
||||||
sort: sort.value === 'name' ? undefined : sort.value,
|
sort: sort.value === 'name' ? undefined : sort.value,
|
||||||
mood: moodFilter.value === 'all' ? undefined : moodFilter.value,
|
mood: moodFilter.value === 'all' ? undefined : moodFilter.value,
|
||||||
|
source: sourceFilter.value === 'all' ? undefined : sourceFilter.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function applyPeopleSavedFilters(filters: Record<string, unknown>) {
|
function applyPeopleSavedFilters(filters: Record<string, unknown>) {
|
||||||
@@ -84,15 +98,28 @@ function applyPeopleSavedFilters(filters: Record<string, unknown>) {
|
|||||||
PERSON_MOODS.includes(filters.mood as PersonMood)
|
PERSON_MOODS.includes(filters.mood as PersonMood)
|
||||||
? (filters.mood as PersonMood)
|
? (filters.mood as PersonMood)
|
||||||
: 'all';
|
: 'all';
|
||||||
|
const nextSource =
|
||||||
|
typeof filters.source === 'string' &&
|
||||||
|
PERSON_IMPORT_SOURCES.includes(filters.source as PersonImportSource)
|
||||||
|
? (filters.source as PersonImportSource)
|
||||||
|
: 'all';
|
||||||
patchQuery({
|
patchQuery({
|
||||||
q: nextQ.trim() || undefined,
|
q: nextQ.trim() || undefined,
|
||||||
sort: nextSort === 'name' ? undefined : nextSort,
|
sort: nextSort === 'name' ? undefined : nextSort,
|
||||||
mood: nextMood === 'all' ? undefined : nextMood,
|
mood: nextMood === 'all' ? undefined : nextMood,
|
||||||
|
source: nextSource === 'all' ? undefined : nextSource,
|
||||||
page: undefined,
|
page: undefined,
|
||||||
tab: undefined,
|
tab: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sourceLabel(source: string) {
|
||||||
|
if (source in PERSON_IMPORT_SOURCE_LABELS) {
|
||||||
|
return PERSON_IMPORT_SOURCE_LABELS[source as PersonImportSource];
|
||||||
|
}
|
||||||
|
return source || 'دستی';
|
||||||
|
}
|
||||||
|
|
||||||
function moodOf(person: PersonDto): PersonMood {
|
function moodOf(person: PersonDto): PersonMood {
|
||||||
return person.mood || 'newcomer';
|
return person.mood || 'newcomer';
|
||||||
}
|
}
|
||||||
@@ -103,6 +130,7 @@ function moodLabel(mood: PersonMood) {
|
|||||||
|
|
||||||
/** کد شخص در نرمافزار مبدأ (دیدار و مشابه) */
|
/** کد شخص در نرمافزار مبدأ (دیدار و مشابه) */
|
||||||
function externalCode(person: PersonDto): string {
|
function externalCode(person: PersonDto): string {
|
||||||
|
if (person.sourceRef?.trim()) return person.sourceRef.trim();
|
||||||
const fields = person.customFields ?? {};
|
const fields = person.customFields ?? {};
|
||||||
for (const key of ['didarId', 'customerCode'] as const) {
|
for (const key of ['didarId', 'customerCode'] as const) {
|
||||||
const raw = fields[key];
|
const raw = fields[key];
|
||||||
@@ -137,16 +165,24 @@ async function load() {
|
|||||||
closePopups();
|
closePopups();
|
||||||
try {
|
try {
|
||||||
if (tab.value === 'people') {
|
if (tab.value === 'people') {
|
||||||
const { data } = await api.get<PaginatedResponse<PersonDto>>('/people', {
|
const [peopleRes, statsRes] = await Promise.all([
|
||||||
params: {
|
api.get<PaginatedResponse<PersonDto>>('/people', {
|
||||||
q: q.value || undefined,
|
params: {
|
||||||
page: page.value,
|
q: q.value || undefined,
|
||||||
limit: PAGE_SIZE,
|
page: page.value,
|
||||||
sort: sort.value,
|
limit: PAGE_SIZE,
|
||||||
mood: moodFilter.value === 'all' ? undefined : moodFilter.value,
|
sort: sort.value,
|
||||||
},
|
mood: moodFilter.value === 'all' ? undefined : moodFilter.value,
|
||||||
});
|
importSource:
|
||||||
people.value = data;
|
sourceFilter.value === 'all' ? undefined : sourceFilter.value,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
api.get<PersonImportSourceStatDto[]>('/people/source-stats').catch(() => ({
|
||||||
|
data: [] as PersonImportSourceStatDto[],
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
people.value = peopleRes.data;
|
||||||
|
sourceStats.value = statsRes.data;
|
||||||
} else {
|
} else {
|
||||||
const { data } = await api.get<PaginatedResponse<CompanyDto>>('/companies', {
|
const { data } = await api.get<PaginatedResponse<CompanyDto>>('/companies', {
|
||||||
params: { q: q.value || undefined, page: page.value, limit: PAGE_SIZE },
|
params: { q: q.value || undefined, page: page.value, limit: PAGE_SIZE },
|
||||||
@@ -194,6 +230,13 @@ function setMood(next: PersonMood | 'all') {
|
|||||||
patchQuery({ mood: next === 'all' ? undefined : next, page: undefined });
|
patchQuery({ mood: next === 'all' ? undefined : next, page: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setSource(next: PersonImportSource | 'all') {
|
||||||
|
patchQuery({
|
||||||
|
source: next === 'all' ? undefined : next,
|
||||||
|
page: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function setPage(next: number) {
|
function setPage(next: number) {
|
||||||
if (next < 1 || next > pageCount.value) return;
|
if (next < 1 || next > pageCount.value) return;
|
||||||
patchQuery({ page: next === 1 ? undefined : String(next) });
|
patchQuery({ page: next === 1 ? undefined : String(next) });
|
||||||
@@ -422,6 +465,28 @@ onMounted(load);
|
|||||||
@apply="applyPeopleSavedFilters"
|
@apply="applyPeopleSavedFilters"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div v-if="tab === 'people' && sourceStats.length" class="source-stats">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="source-chip"
|
||||||
|
:class="{ on: sourceFilter === 'all' }"
|
||||||
|
@click="setSource('all')"
|
||||||
|
>
|
||||||
|
همه منابع
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="row in sourceStats"
|
||||||
|
:key="row.source"
|
||||||
|
type="button"
|
||||||
|
class="source-chip"
|
||||||
|
:class="{ on: sourceFilter === row.source }"
|
||||||
|
@click="setSource(row.source as PersonImportSource)"
|
||||||
|
>
|
||||||
|
{{ row.label }}
|
||||||
|
<em>{{ toFaDigits(row.count) }}</em>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
|
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||||||
@@ -460,8 +525,12 @@ onMounted(load);
|
|||||||
<p class="role">
|
<p class="role">
|
||||||
{{ [p.position, p.company?.name].filter(Boolean).join(' • ') || 'بدون سمت' }}
|
{{ [p.position, p.company?.name].filter(Boolean).join(' • ') || 'بدون سمت' }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="externalCode(p)" class="line">
|
<p class="line">
|
||||||
<span>کد مبدأ: {{ externalCodeLabel(p) }}</span>
|
<span>منبع: {{ sourceLabel(p.importSource || 'manual') }}</span>
|
||||||
|
<span v-if="externalCode(p)"> · کد: {{ externalCodeLabel(p) }}</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="p.previousFullName" class="line muted">
|
||||||
|
نام قبلی: {{ p.previousFullName }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="p.mobile || p.phone" class="line" dir="ltr">
|
<p v-if="p.mobile || p.phone" class="line" dir="ltr">
|
||||||
<UiIcon name="phone" :size="13" />
|
<UiIcon name="phone" :size="13" />
|
||||||
@@ -482,6 +551,7 @@ onMounted(load);
|
|||||||
<tr>
|
<tr>
|
||||||
<th>وضعیت</th>
|
<th>وضعیت</th>
|
||||||
<th>نام شخص</th>
|
<th>نام شخص</th>
|
||||||
|
<th>منبع</th>
|
||||||
<th>کد مبدأ</th>
|
<th>کد مبدأ</th>
|
||||||
<th>سمت</th>
|
<th>سمت</th>
|
||||||
<th>شرکت</th>
|
<th>شرکت</th>
|
||||||
@@ -504,7 +574,11 @@ onMounted(load);
|
|||||||
<RouterLink class="name" :to="{ name: 'person-detail', params: { id: p.id } }">
|
<RouterLink class="name" :to="{ name: 'person-detail', params: { id: p.id } }">
|
||||||
{{ p.fullName }}
|
{{ p.fullName }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<div v-if="p.previousFullName" class="muted tiny">
|
||||||
|
قبلی: {{ p.previousFullName }}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{ sourceLabel(p.importSource || 'manual') }}</td>
|
||||||
<td dir="ltr">{{ externalCodeLabel(p) }}</td>
|
<td dir="ltr">{{ externalCodeLabel(p) }}</td>
|
||||||
<td>{{ p.position || '—' }}</td>
|
<td>{{ p.position || '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -752,6 +826,37 @@ h1 {
|
|||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.source-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.source-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border: 1px solid var(--color-border, #ddd);
|
||||||
|
background: var(--color-surface, #fff);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.25rem 0.7rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.source-chip.on {
|
||||||
|
border-color: var(--color-primary, #2563eb);
|
||||||
|
color: var(--color-primary, #2563eb);
|
||||||
|
background: color-mix(in srgb, var(--color-primary, #2563eb) 10%, transparent);
|
||||||
|
}
|
||||||
|
.source-chip em {
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.tiny {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
|
|||||||
@@ -47,6 +47,19 @@ const callFollowUp = ref('');
|
|||||||
const noteBody = ref('');
|
const noteBody = ref('');
|
||||||
const followUpAt = ref('');
|
const followUpAt = ref('');
|
||||||
const followUpBody = ref('');
|
const followUpBody = ref('');
|
||||||
|
const editing = ref(false);
|
||||||
|
const editForm = ref({
|
||||||
|
title: '',
|
||||||
|
contactName: '',
|
||||||
|
mobile: '',
|
||||||
|
phone: '',
|
||||||
|
city: '',
|
||||||
|
region: '',
|
||||||
|
source: '',
|
||||||
|
instagram: '',
|
||||||
|
trade: '',
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
|
||||||
function formatDate(value: string | null | undefined) {
|
function formatDate(value: string | null | undefined) {
|
||||||
if (!value) return '—';
|
if (!value) return '—';
|
||||||
@@ -88,6 +101,53 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
if (!lead.value) return;
|
||||||
|
editing.value = true;
|
||||||
|
editForm.value = {
|
||||||
|
title: lead.value.title,
|
||||||
|
contactName: lead.value.contactName ?? '',
|
||||||
|
mobile: lead.value.mobile,
|
||||||
|
phone: lead.value.phone ?? '',
|
||||||
|
city: lead.value.city ?? '',
|
||||||
|
region: lead.value.region ?? '',
|
||||||
|
source: lead.value.source ?? '',
|
||||||
|
instagram: lead.value.instagram ?? '',
|
||||||
|
trade: lead.value.trade ?? '',
|
||||||
|
notes: lead.value.notes ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
if (!editForm.value.title.trim() || !editForm.value.mobile.trim()) {
|
||||||
|
error.value = 'عنوان و موبایل الزامی است.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
await api.patch(`/leads/${id.value}`, {
|
||||||
|
title: editForm.value.title.trim(),
|
||||||
|
contactName: editForm.value.contactName.trim() || null,
|
||||||
|
mobile: editForm.value.mobile.trim(),
|
||||||
|
phone: editForm.value.phone.trim() || null,
|
||||||
|
city: editForm.value.city.trim() || null,
|
||||||
|
region: editForm.value.region.trim() || null,
|
||||||
|
source: editForm.value.source.trim() || null,
|
||||||
|
instagram: editForm.value.instagram.trim() || null,
|
||||||
|
trade: editForm.value.trade.trim() || null,
|
||||||
|
notes: editForm.value.notes.trim() || null,
|
||||||
|
});
|
||||||
|
okMsg.value = 'لید ویرایش شد.';
|
||||||
|
editing.value = false;
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiError(e, 'ویرایش لید ناموفق بود.');
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function changeStage() {
|
async function changeStage() {
|
||||||
if (!stageId.value || stageId.value === lead.value?.stageId) return;
|
if (!stageId.value || stageId.value === lead.value?.stageId) return;
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
@@ -226,6 +286,15 @@ onMounted(load);
|
|||||||
:back-to="{ name: 'leads' }"
|
:back-to="{ name: 'leads' }"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
|
<UiButton
|
||||||
|
v-if="lead && !editing"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="startEdit"
|
||||||
|
>
|
||||||
|
ویرایش
|
||||||
|
</UiButton>
|
||||||
<UiButton
|
<UiButton
|
||||||
v-if="lead && !lead.convertedPersonId"
|
v-if="lead && !lead.convertedPersonId"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -255,7 +324,45 @@ onMounted(load);
|
|||||||
<div class="layout">
|
<div class="layout">
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<UiCard title="اطلاعات لید">
|
<UiCard title="اطلاعات لید">
|
||||||
<dl class="meta">
|
<div v-if="editing" class="edit-grid">
|
||||||
|
<UiField label="عنوان" required>
|
||||||
|
<UiInput v-model="editForm.title" />
|
||||||
|
</UiField>
|
||||||
|
<UiField label="مالک/مخاطب">
|
||||||
|
<UiInput v-model="editForm.contactName" />
|
||||||
|
</UiField>
|
||||||
|
<UiField label="موبایل" required>
|
||||||
|
<UiInput v-model="editForm.mobile" dir="ltr" />
|
||||||
|
</UiField>
|
||||||
|
<UiField label="تلفن">
|
||||||
|
<UiInput v-model="editForm.phone" dir="ltr" />
|
||||||
|
</UiField>
|
||||||
|
<UiField label="شهر">
|
||||||
|
<UiInput v-model="editForm.city" />
|
||||||
|
</UiField>
|
||||||
|
<UiField label="منطقه">
|
||||||
|
<UiInput v-model="editForm.region" />
|
||||||
|
</UiField>
|
||||||
|
<UiField label="منبع">
|
||||||
|
<UiInput v-model="editForm.source" />
|
||||||
|
</UiField>
|
||||||
|
<UiField label="اینستاگرام">
|
||||||
|
<UiInput v-model="editForm.instagram" dir="ltr" />
|
||||||
|
</UiField>
|
||||||
|
<UiField label="صنف">
|
||||||
|
<UiInput v-model="editForm.trade" />
|
||||||
|
</UiField>
|
||||||
|
<UiField label="یادداشت" class="full">
|
||||||
|
<UiInput v-model="editForm.notes" />
|
||||||
|
</UiField>
|
||||||
|
<div class="edit-actions full">
|
||||||
|
<UiButton size="sm" :loading="busy" @click="saveEdit">ذخیره</UiButton>
|
||||||
|
<UiButton size="sm" variant="ghost" :disabled="busy" @click="editing = false">
|
||||||
|
انصراف
|
||||||
|
</UiButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<dl v-else class="meta">
|
||||||
<div><dt>نام</dt><dd>{{ lead.title }}</dd></div>
|
<div><dt>نام</dt><dd>{{ lead.title }}</dd></div>
|
||||||
<div><dt>مالک/مخاطب</dt><dd>{{ lead.contactName || '—' }}</dd></div>
|
<div><dt>مالک/مخاطب</dt><dd>{{ lead.contactName || '—' }}</dd></div>
|
||||||
<div><dt>موبایل</dt><dd dir="ltr">{{ lead.mobile }}</dd></div>
|
<div><dt>موبایل</dt><dd dir="ltr">{{ lead.mobile }}</dd></div>
|
||||||
@@ -384,6 +491,20 @@ onMounted(load);
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
.edit-grid .full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.edit-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ const registeredAt = computed(() =>
|
|||||||
person.value?.createdAt ? formatJalaliNumeric(new Date(person.value.createdAt)) : '—',
|
person.value?.createdAt ? formatJalaliNumeric(new Date(person.value.createdAt)) : '—',
|
||||||
);
|
);
|
||||||
const externalCode = computed(() => {
|
const externalCode = computed(() => {
|
||||||
|
if (person.value?.sourceRef?.trim()) return person.value.sourceRef.trim();
|
||||||
const fields = person.value?.customFields ?? {};
|
const fields = person.value?.customFields ?? {};
|
||||||
for (const key of ['didarId', 'customerCode'] as const) {
|
for (const key of ['didarId', 'customerCode'] as const) {
|
||||||
const raw = fields[key];
|
const raw = fields[key];
|
||||||
@@ -146,6 +147,19 @@ const externalCode = computed(() => {
|
|||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
});
|
});
|
||||||
|
const importSourceLabel = computed(() => {
|
||||||
|
const src = person.value?.importSource || 'manual';
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
manual: 'دستی',
|
||||||
|
tahesab: 'تهحساب',
|
||||||
|
didar: 'دیدار',
|
||||||
|
excel: 'اکسل',
|
||||||
|
phone: 'مخاطبین گوشی',
|
||||||
|
lead_form: 'فرم لید',
|
||||||
|
other: 'سایر',
|
||||||
|
};
|
||||||
|
return map[src] || src;
|
||||||
|
});
|
||||||
|
|
||||||
const lastCallAt = computed(() => {
|
const lastCallAt = computed(() => {
|
||||||
const calls = activities.value
|
const calls = activities.value
|
||||||
@@ -630,7 +644,9 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
|||||||
</div>
|
</div>
|
||||||
<dl class="facts">
|
<dl class="facts">
|
||||||
<div><dt>سمت</dt><dd>{{ person.position || '—' }}</dd></div>
|
<div><dt>سمت</dt><dd>{{ person.position || '—' }}</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.website || '—' }}</dd></div>
|
<div><dt>وبسایت</dt><dd>{{ person.website || '—' }}</dd></div>
|
||||||
<div><dt>اینستاگرام</dt><dd dir="ltr">{{ person.instagram || '—' }}</dd></div>
|
<div><dt>اینستاگرام</dt><dd dir="ltr">{{ person.instagram || '—' }}</dd></div>
|
||||||
<div><dt>معرف</dt><dd>{{ person.referrer || '—' }}</dd></div>
|
<div><dt>معرف</dt><dd>{{ person.referrer || '—' }}</dd></div>
|
||||||
@@ -916,7 +932,9 @@ 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>{{ 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.website || '—' }}</dd></div>
|
<div><dt>وبسایت</dt><dd>{{ person.website || '—' }}</dd></div>
|
||||||
<div><dt>اینستاگرام</dt><dd dir="ltr">{{ person.instagram || '—' }}</dd></div>
|
<div><dt>اینستاگرام</dt><dd dir="ltr">{{ person.instagram || '—' }}</dd></div>
|
||||||
<div><dt>معرف</dt><dd>{{ person.referrer || '—' }}</dd></div>
|
<div><dt>معرف</dt><dd>{{ person.referrer || '—' }}</dd></div>
|
||||||
|
|||||||
@@ -702,10 +702,43 @@ export const PERSON_MOOD_LABELS: Record<PersonMood, string> = {
|
|||||||
special: "مشتری خاص",
|
special: "مشتری خاص",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** منبع ورود شخص به CRM */
|
||||||
|
export const PERSON_IMPORT_SOURCES = [
|
||||||
|
"manual",
|
||||||
|
"tahesab",
|
||||||
|
"didar",
|
||||||
|
"excel",
|
||||||
|
"phone",
|
||||||
|
"lead_form",
|
||||||
|
"other",
|
||||||
|
] as const;
|
||||||
|
export type PersonImportSource = (typeof PERSON_IMPORT_SOURCES)[number];
|
||||||
|
|
||||||
|
export const PERSON_IMPORT_SOURCE_LABELS: Record<PersonImportSource, string> = {
|
||||||
|
manual: "دستی",
|
||||||
|
tahesab: "تهحساب",
|
||||||
|
didar: "دیدار",
|
||||||
|
excel: "اکسل",
|
||||||
|
phone: "مخاطبین گوشی",
|
||||||
|
lead_form: "فرم لید",
|
||||||
|
other: "سایر",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PersonImportSourceStatDto {
|
||||||
|
source: PersonImportSource | string;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PersonDto {
|
export interface PersonDto {
|
||||||
id: string;
|
id: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
|
previousFirstName: string | null;
|
||||||
|
previousLastName: string | null;
|
||||||
|
previousFullName: string | null;
|
||||||
|
importSource: PersonImportSource | string;
|
||||||
|
sourceRef: string | null;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
@@ -730,6 +763,10 @@ export interface PersonDto {
|
|||||||
export interface CreatePersonRequest {
|
export interface CreatePersonRequest {
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
|
previousFirstName?: string | null;
|
||||||
|
previousLastName?: string | null;
|
||||||
|
importSource?: PersonImportSource | string;
|
||||||
|
sourceRef?: string | null;
|
||||||
email?: string;
|
email?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
mobile?: string;
|
mobile?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user