Align companies with the Didar export: new fields, Didar-format Excel import/export, and people linked to companies by Didar code

This commit is contained in:
2026-10-06 12:25:20 +03:30
parent f88786ef61
commit f71f09c85d
8 changed files with 630 additions and 85 deletions
@@ -0,0 +1,33 @@
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "code" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "importSource" TEXT NOT NULL DEFAULT 'manual';
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "sourceRef" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "economicCode" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "registrationNumber" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "otherPhones" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "fax" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "otherEmails" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "province" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "city" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "postalCode" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "groupName" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "registeredAt" TIMESTAMP(3);
CREATE INDEX IF NOT EXISTS "Company_organizationId_sourceRef_idx" ON "Company"("organizationId", "sourceRef");
CREATE INDEX IF NOT EXISTS "Company_organizationId_code_idx" ON "Company"("organizationId", "code");
-- شرکت‌هایی که از ورود اشخاص دیدار ساخته شده‌اند: کد دیدار شرکت را از customFields اشخاص بازیابی کن
UPDATE "Company" c
SET "sourceRef" = sub.didar_code,
"importSource" = 'didar'
FROM (
SELECT DISTINCT ON (p."companyId")
p."companyId" AS company_id,
TRIM(p."customFields"->>'companyDidarId') AS didar_code
FROM "Person" p
WHERE p."companyId" IS NOT NULL
AND jsonb_typeof(p."customFields") = 'object'
AND COALESCE(TRIM(p."customFields"->>'companyDidarId'), '') <> ''
ORDER BY p."companyId", p."createdAt"
) sub
WHERE c."id" = sub.company_id
AND c."sourceRef" IS NULL;
+18
View File
@@ -398,12 +398,28 @@ model Company {
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
/// کد داخلی شرکت (مثل «کد شرکت» در دیدار)
code String?
/// منبع ورود: manual | didar | tahesab | excel | other
importSource String @default("manual")
/// شناسه در مبدأ (مثلاً کد دیدار شرکت)
sourceRef String?
nationalId String?
economicCode String?
registrationNumber String?
phone String?
otherPhones String?
fax String?
email String?
otherEmails String?
website String?
province String?
city String?
postalCode String?
address String?
groupName String?
notes String?
registeredAt DateTime?
tags String[] @default([])
customFields Json @default("{}")
people Person[]
@@ -423,6 +439,8 @@ model Company {
@@index([organizationId, name])
@@index([organizationId, email])
@@index([organizationId, phone])
@@index([organizationId, sourceRef])
@@index([organizationId, code])
@@index([ownerId])
}
@@ -24,6 +24,13 @@ import {
resolveOwnerId,
} from '../common/record-scope';
import { normalizeTags } from '../common/tags';
import { normalizePersonImportSource } from './person-source';
function parseDateOrNull(raw?: string | null): Date | null {
if (!raw?.trim()) return null;
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? null : d;
}
const companyInclude = {
_count: { select: { people: true } },
@@ -33,12 +40,25 @@ const companyInclude = {
type CompanyRow = {
id: string;
name: string;
code: string | null;
importSource: string;
sourceRef: string | null;
nationalId: string | null;
economicCode: string | null;
registrationNumber: string | null;
phone: string | null;
otherPhones: string | null;
fax: string | null;
email: string | null;
otherEmails: string | null;
website: string | null;
province: string | null;
city: string | null;
postalCode: string | null;
address: string | null;
groupName: string | null;
notes: string | null;
registeredAt: Date | null;
tags: string[];
customFields: unknown;
createdAt: Date;
@@ -67,6 +87,9 @@ export class CompaniesService {
{ email: { contains: q.trim(), mode: 'insensitive' } },
{ phone: { contains: q.trim() } },
{ nationalId: { contains: q.trim() } },
{ code: { contains: q.trim() } },
{ sourceRef: { contains: q.trim() } },
{ city: { contains: q.trim(), mode: 'insensitive' } },
],
}
: {}),
@@ -104,12 +127,25 @@ export class CompaniesService {
const data: Prisma.CompanyUncheckedCreateInput = {
organizationId: actor.organizationId,
name: dto.name.trim(),
code: emptyToNull(dto.code),
importSource: normalizePersonImportSource(dto.importSource),
sourceRef: emptyToNull(dto.sourceRef),
nationalId: emptyToNull(dto.nationalId),
economicCode: emptyToNull(dto.economicCode),
registrationNumber: emptyToNull(dto.registrationNumber),
phone: normalizePhone(dto.phone),
otherPhones: emptyToNull(dto.otherPhones),
fax: emptyToNull(dto.fax),
email: normalizeEmail(dto.email),
otherEmails: emptyToNull(dto.otherEmails),
website: emptyToNull(dto.website),
province: emptyToNull(dto.province),
city: emptyToNull(dto.city),
postalCode: emptyToNull(dto.postalCode),
address: emptyToNull(dto.address),
groupName: emptyToNull(dto.groupName),
notes: emptyToNull(dto.notes),
registeredAt: parseDateOrNull(dto.registeredAt),
tags: normalizeTags(dto.tags),
ownerId: resolveOwnerId(actor, dto.ownerId),
customFields: toJsonCustomFields(dto.customFields),
@@ -131,12 +167,29 @@ export class CompaniesService {
await this.ensureExists(actor.organizationId, id, actor);
const data: Prisma.CompanyUncheckedUpdateInput = {};
if (dto.name !== undefined) data.name = dto.name.trim();
if ('code' in dto) data.code = emptyToNull(dto.code);
if (dto.importSource !== undefined) {
data.importSource = normalizePersonImportSource(dto.importSource);
}
if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef);
if ('nationalId' in dto) data.nationalId = emptyToNull(dto.nationalId);
if ('economicCode' in dto) data.economicCode = emptyToNull(dto.economicCode);
if ('registrationNumber' in dto) {
data.registrationNumber = emptyToNull(dto.registrationNumber);
}
if ('phone' in dto) data.phone = normalizePhone(dto.phone);
if ('otherPhones' in dto) data.otherPhones = emptyToNull(dto.otherPhones);
if ('fax' in dto) data.fax = emptyToNull(dto.fax);
if ('email' in dto) data.email = normalizeEmail(dto.email);
if ('otherEmails' in dto) data.otherEmails = emptyToNull(dto.otherEmails);
if ('website' in dto) data.website = emptyToNull(dto.website);
if ('province' in dto) data.province = emptyToNull(dto.province);
if ('city' in dto) data.city = emptyToNull(dto.city);
if ('postalCode' in dto) data.postalCode = emptyToNull(dto.postalCode);
if ('address' in dto) data.address = emptyToNull(dto.address);
if ('groupName' in dto) data.groupName = emptyToNull(dto.groupName);
if ('notes' in dto) data.notes = emptyToNull(dto.notes);
if ('registeredAt' in dto) data.registeredAt = parseDateOrNull(dto.registeredAt);
if ('tags' in dto) data.tags = normalizeTags(dto.tags);
if ('customFields' in dto) {
data.customFields = toJsonCustomFields(dto.customFields);
@@ -226,12 +279,25 @@ export class CompaniesService {
return {
id: row.id,
name: row.name,
code: row.code,
importSource: row.importSource || 'manual',
sourceRef: row.sourceRef,
nationalId: row.nationalId,
economicCode: row.economicCode,
registrationNumber: row.registrationNumber,
phone: row.phone,
otherPhones: row.otherPhones,
fax: row.fax,
email: row.email,
otherEmails: row.otherEmails,
website: row.website,
province: row.province,
city: row.city,
postalCode: row.postalCode,
address: row.address,
groupName: row.groupName,
notes: row.notes,
registeredAt: row.registeredAt ? row.registeredAt.toISOString() : null,
tags: row.tags ?? [],
customFields: asCustomFields(row.customFields),
peopleCount: row._count.people,
+265 -39
View File
@@ -167,34 +167,116 @@ const PERSON_FIELDS = [
];
const COMPANY_ALIASES: Record<string, string> = {
type: '_type',
_type: '_type',
name: 'name',
نام: 'name',
نامشرکت: 'name',
کددیدارشرکت: 'didarId',
didarid: 'didarId',
کدشرکت: 'code',
code: 'code',
استانفیلدپیشفرض: 'province',
استان: 'province',
province: 'province',
شهرستان: 'city',
شهر: 'city',
city: 'city',
nationalid: 'nationalId',
شناسهملی: 'nationalId',
شمارهاقتصادی: 'economicCode',
کداقتصادی: 'economicCode',
شمارهثبت: 'registrationNumber',
phone: 'phone',
تلفن: 'phone',
تلفنشرکت: 'phone',
تلفنثابتشرکت: 'phone',
تلفنهایدیگرشرکت: 'otherPhones',
شمارهفکس: 'fax',
فکس: 'fax',
fax: 'fax',
email: 'email',
ایمیل: 'email',
ایمیلشرکت: 'email',
ایمیلهایدیگرشرکت: 'otherEmails',
website: 'website',
وبسایت: 'website',
سایتهایشرکت: 'website',
کدپستی: 'postalCode',
postalcode: 'postalCode',
address: 'address',
آدرس: 'address',
notes: 'notes',
یادداشت: 'notes',
توضیحاتشرکت: 'notes',
مسئولشرکت: 'ownerName',
مسئول: 'ownerName',
owner: 'ownerName',
مجوزمشاهدهشرکت: 'visibility',
تاریخثبتشرکت: 'registeredAt',
تاریخثبت: 'registeredAt',
گروهبندیشرکت: 'groupName',
گروه: 'groupName',
};
const COMPANY_FIELDS = [
{ key: 'name', label: 'نام شرکت', required: true },
{ key: 'didarId', label: 'کد دیدار شرکت' },
{ key: 'code', label: 'کد شرکت' },
{ key: 'province', label: 'استان' },
{ key: 'city', label: 'شهرستان' },
{ key: 'phone', label: 'تلفن شرکت' },
{ key: 'email', label: 'ایمیل شرکت' },
{ key: 'economicCode', label: 'شماره اقتصادی' },
{ key: 'registrationNumber', label: 'شماره ثبت' },
{ key: 'nationalId', label: 'شناسه ملی' },
{ key: 'phone', label: 'تلفن' },
{ key: 'email', label: 'ایمیل' },
{ key: 'website', label: 'وب‌سایت' },
{ key: 'fax', label: 'شماره فکس' },
{ key: 'postalCode', label: 'کد پستی' },
{ key: 'notes', label: 'توضیحات شرکت' },
{ key: 'ownerName', label: 'مسئول شرکت' },
{ key: 'visibility', label: 'مجوز مشاهده شرکت' },
{ key: 'registeredAt', label: 'تاریخ ثبت شرکت' },
{ key: 'groupName', label: 'گروه بندی شرکت' },
{ key: 'otherPhones', label: 'تلفن های دیگر شرکت' },
{ key: 'address', label: 'آدرس' },
{ key: 'notes', label: 'یادداشت' },
{ key: 'website', label: 'سایت های شرکت' },
{ key: 'otherEmails', label: 'ایمیل های دیگر شرکت' },
];
const COMPANY_COLUMNS = [
{ header: '_type', key: '_type', width: 12 },
{ header: 'کد دیدار شرکت', key: 'didarId', width: 14 },
{ header: 'کد شرکت', key: 'code', width: 12 },
{ header: 'نام شرکت', key: 'name', width: 28 },
{ header: 'استان (فیلد پیش فرض)', key: 'province', width: 16 },
{ header: 'شهرستان', key: 'city', width: 14 },
{ header: 'تلفن شرکت', key: 'phone', width: 16 },
{ header: 'ایمیل شرکت', key: 'email', width: 24 },
{ header: 'شماره اقتصادی', key: 'economicCode', width: 16 },
{ header: 'شماره ثبت', key: 'registrationNumber', width: 14 },
{ header: 'شناسه ملی', key: 'nationalId', width: 16 },
{ header: 'شماره فکس', key: 'fax', width: 14 },
{ header: 'کد پستی', key: 'postalCode', width: 12 },
{ header: 'توضیحات شرکت', key: 'notes', width: 32 },
{ header: 'مسئول شرکت', key: 'ownerName', width: 16 },
{ header: 'مجوز مشاهده شرکت', key: 'visibility', width: 18 },
{ header: 'تاریخ ثبت شرکت', key: 'registeredAt', width: 16 },
{ header: 'گروه بندی شرکت', key: 'groupName', width: 16 },
{ header: 'تلفن های دیگر شرکت', key: 'otherPhones', width: 18 },
{ header: 'آدرس', key: 'address', width: 28 },
{ header: 'سایت های شرکت', key: 'website', width: 20 },
{ header: 'ایمیل های دیگر شرکت', key: 'otherEmails', width: 20 },
];
function parseLooseDate(raw?: string): Date | null {
const jalali = parseJalaliDateTime(raw);
if (jalali) return jalali;
const v = raw?.trim();
if (!v) return null;
const d = new Date(v);
return Number.isNaN(d.getTime()) ? null : d;
}
const CONTACT_COLUMNS = [
{ header: '_type', key: '_type', width: 12 },
{ header: 'کد دیدار مشتری', key: 'didarId', width: 16 },
@@ -342,25 +424,37 @@ export class ContactsExcelService {
async exportCompanies(actor: Actor, q?: string): Promise<Buffer> {
const { items } = await this.companies.list(actor, q, 1, 5000);
return writeXlsx(
'شرکت‌ها',
[
{ header: 'نام شرکت', key: 'name', width: 28 },
{ header: 'شناسه ملی', key: 'nationalId', width: 16 },
{ header: 'تلفن', key: 'phone', width: 16 },
{ header: 'ایمیل', key: 'email', width: 28 },
{ header: 'وب‌سایت', key: 'website', width: 24 },
{ header: 'آدرس', key: 'address', width: 32 },
{ header: 'یادداشت', key: 'notes', width: 32 },
],
items.map((c) => ({
name: c.name,
nationalId: c.nationalId ?? '',
phone: c.phone ?? '',
email: c.email ?? '',
website: c.website ?? '',
address: c.address ?? '',
notes: c.notes ?? '',
})),
'Company',
COMPANY_COLUMNS,
items.map((c) => {
const extra = (c.customFields ?? {}) as Record<string, string>;
return {
_type: 'Company',
didarId: c.importSource === 'didar' && c.sourceRef ? c.sourceRef : '',
code: c.code ?? '',
name: c.name,
province: c.province ?? '',
city: c.city ?? '',
phone: c.phone ?? '',
email: c.email ?? '',
economicCode: c.economicCode ?? '',
registrationNumber: c.registrationNumber ?? '',
nationalId: c.nationalId ?? '',
fax: c.fax ?? '',
postalCode: c.postalCode ?? '',
notes: c.notes ?? '',
ownerName: c.owner?.fullName ?? '',
visibility: extra.visibility || 'همه افراد شرکت',
registeredAt: formatJalaliDate(
c.registeredAt ? new Date(c.registeredAt) : new Date(c.createdAt),
),
groupName: c.groupName ?? '',
otherPhones: c.otherPhones ?? '',
address: c.address ?? '',
website: c.website ?? '',
otherEmails: c.otherEmails ?? '',
};
}),
);
}
@@ -414,7 +508,12 @@ export class ContactsExcelService {
let companyId: string | null = null;
const companyName = row.companyName?.trim();
if (companyName) {
companyId = await this.findOrCreateCompanyId(actor, companyName);
companyId = await this.findOrCreateCompanyId(
actor,
companyName,
row.companyDidarId,
row.companyPhone,
);
}
const ownerName = row.ownerName?.trim() ?? '';
@@ -527,26 +626,91 @@ export class ContactsExcelService {
readMappedRows(file!, COMPANY_ALIASES, parseExcelMapping(mappingRaw)),
});
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
const ownerCache = new Map<string, string | null>();
for (let i = 0; i < ctx.slice.length; i++) {
const row = ctx.slice[i]!;
const rowNum = ctx.offset + i + 2;
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'company') {
continue;
}
const name = row.name?.trim() ?? '';
if (!name) {
result.errors.push({ row: rowNum, message: 'نام شرکت الزامی است' });
continue;
}
const ownerName = row.ownerName?.trim() ?? '';
let ownerId: string | undefined;
let ownerMissing = false;
if (ownerName) {
const key = ownerName.toLowerCase();
if (!ownerCache.has(key)) {
ownerCache.set(key, await this.findUserId(actor.organizationId, ownerName));
}
const matched = ownerCache.get(key);
if (matched) ownerId = matched;
else ownerMissing = true;
}
const didarId = row.didarId?.trim() || '';
const customFields: Record<string, string> = {};
const visibility = row.visibility?.trim();
if (visibility) customFields.visibility = visibility;
const registeredAt = parseLooseDate(row.registeredAt);
const payload = {
name,
code: row.code?.trim() || null,
importSource: didarId ? 'didar' : 'excel',
sourceRef: didarId || null,
nationalId: row.nationalId || undefined,
economicCode: row.economicCode?.trim() || null,
registrationNumber: row.registrationNumber?.trim() || null,
phone: row.phone || undefined,
otherPhones: row.otherPhones?.trim() || null,
fax: row.fax?.trim() || null,
email: row.email || undefined,
otherEmails: row.otherEmails?.trim() || null,
website: row.website || undefined,
province: row.province?.trim() || null,
city: row.city?.trim() || null,
postalCode: row.postalCode?.trim() || null,
address: row.address || undefined,
groupName: row.groupName?.trim() || null,
notes: row.notes || undefined,
registeredAt: registeredAt?.toISOString() ?? null,
customFields,
};
try {
await this.companies.create(actor, {
name,
nationalId: row.nationalId || undefined,
phone: row.phone || undefined,
email: row.email || undefined,
website: row.website || undefined,
address: row.address || undefined,
notes: row.notes || undefined,
});
result.created += 1;
const existing = await this.findImportedCompany(actor, didarId, name);
if (existing) {
const merged: Record<string, unknown> = {};
for (const [k, v] of Object.entries(payload)) {
if (k === 'customFields') continue;
if (v != null && v !== '') merged[k] = v;
}
merged.customFields = {
...((existing.customFields ?? {}) as Record<string, unknown>),
...customFields,
};
if (ownerId) merged.ownerId = ownerId;
await this.companies.update(actor, existing.id, merged);
result.skipped += 1;
} else {
await this.companies.create(actor, {
...payload,
ownerId: resolveOwnerId(actor, ownerId ?? null),
});
result.created += 1;
}
if (ownerMissing) {
result.errors.push({
row: rowNum,
message: `مسئول شرکت «${ownerName}» در تیم پیدا نشد؛ به کاربر ایمپورتر اختصاص داده شد`,
});
}
} catch (e) {
if (e instanceof ConflictException) {
result.skipped += 1;
@@ -562,18 +726,80 @@ export class ContactsExcelService {
return { ...result, ...chunkImportMeta(ctx) };
}
private async findOrCreateCompanyId(actor: Actor, name: string): Promise<string> {
/** شرکت موجود برای ورود مجدد: اول با کد دیدار، بعد با نام (اگر کد مبدأ نداشته باشد). */
private async findImportedCompany(actor: Actor, didarId: string, name: string) {
if (didarId) {
const byRef = await this.prisma.company.findFirst({
where: {
organizationId: actor.organizationId,
sourceRef: didarId,
...ownerWhere(actor),
},
select: { id: true, customFields: true },
});
if (byRef) return byRef;
}
return this.prisma.company.findFirst({
where: {
organizationId: actor.organizationId,
name: { equals: name, mode: 'insensitive' },
...(didarId ? { sourceRef: null } : {}),
...ownerWhere(actor),
},
select: { id: true, customFields: true },
});
}
private async findOrCreateCompanyId(
actor: Actor,
name: string,
didarId?: string,
phone?: string,
): Promise<string> {
const ref = didarId?.trim() || '';
if (ref) {
const byRef = await this.prisma.company.findFirst({
where: {
organizationId: actor.organizationId,
sourceRef: ref,
...ownerWhere(actor),
},
select: { id: true },
});
if (byRef) return byRef.id;
}
const existing = await this.prisma.company.findFirst({
where: {
organizationId: actor.organizationId,
name: { equals: name, mode: 'insensitive' },
...ownerWhere(actor),
},
select: { id: true },
select: { id: true, sourceRef: true },
});
if (existing) return existing.id;
const created = await this.companies.create(actor, { name });
return created.id;
if (existing) {
if (ref && !existing.sourceRef) {
await this.prisma.company.update({
where: { id: existing.id },
data: { sourceRef: ref, importSource: 'didar' },
});
}
return existing.id;
}
try {
const created = await this.companies.create(actor, {
name,
...(ref ? { sourceRef: ref, importSource: 'didar' } : {}),
...(phone?.trim() ? { phone: phone.trim() } : {}),
});
return created.id;
} catch (e) {
if (!(e instanceof ConflictException) || !phone) throw e;
const created = await this.companies.create(actor, {
name,
...(ref ? { sourceRef: ref, importSource: 'didar' } : {}),
});
return created.id;
}
}
private async findUserId(organizationId: string, name?: string) {
+69 -2
View File
@@ -58,7 +58,74 @@ export class ListQueryDto {
importSource?: string;
}
export class CreateCompanyDto {
class CompanyExtraFieldsDto {
@IsOptional()
@IsString()
@MaxLength(60)
code?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
importSource?: string;
@IsOptional()
@IsString()
@MaxLength(80)
sourceRef?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
economicCode?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
registrationNumber?: string | null;
@IsOptional()
@IsString()
@MaxLength(400)
otherPhones?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
fax?: string | null;
@IsOptional()
@IsString()
@MaxLength(400)
otherEmails?: string | null;
@IsOptional()
@IsString()
@MaxLength(80)
province?: string | null;
@IsOptional()
@IsString()
@MaxLength(80)
city?: string | null;
@IsOptional()
@IsString()
@MaxLength(20)
postalCode?: string | null;
@IsOptional()
@IsString()
@MaxLength(120)
groupName?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
registeredAt?: string | null;
}
export class CreateCompanyDto extends CompanyExtraFieldsDto {
@IsString()
@MinLength(2)
@MaxLength(160)
@@ -109,7 +176,7 @@ export class CreateCompanyDto {
ownerId?: string | null;
}
export class UpdateCompanyDto {
export class UpdateCompanyDto extends CompanyExtraFieldsDto {
@IsOptional()
@IsString()
@MinLength(2)
+116 -39
View File
@@ -6,6 +6,8 @@ import { api } from '@/lib/api';
import type { CompanyDto, CreateCompanyRequest } from '@mento/shared';
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import EntityTimeline from '@/components/EntityTimeline.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { formatLocalDateInput, parseLocalInput } from '@/lib/jalali';
import {
UiButton,
UiCard,
@@ -39,11 +41,24 @@ const saving = ref(false);
const error = ref('');
const name = ref('');
const code = ref('');
const sourceRef = ref('');
const importSource = ref('manual');
const nationalId = ref('');
const economicCode = ref('');
const registrationNumber = ref('');
const phone = ref('');
const otherPhones = ref('');
const fax = ref('');
const email = ref('');
const otherEmails = ref('');
const website = ref('');
const province = ref('');
const city = ref('');
const postalCode = ref('');
const address = ref('');
const groupName = ref('');
const registeredAt = ref('');
const notes = ref('');
const tagsRaw = ref('');
const customFields = ref<Record<string, unknown>>({});
@@ -61,11 +76,24 @@ function parseTags(raw: string): string[] {
function fill(c: CompanyDto) {
name.value = c.name;
code.value = c.code ?? '';
sourceRef.value = c.sourceRef ?? '';
importSource.value = c.importSource || 'manual';
nationalId.value = c.nationalId ?? '';
economicCode.value = c.economicCode ?? '';
registrationNumber.value = c.registrationNumber ?? '';
phone.value = c.phone ?? '';
otherPhones.value = c.otherPhones ?? '';
fax.value = c.fax ?? '';
email.value = c.email ?? '';
otherEmails.value = c.otherEmails ?? '';
website.value = c.website ?? '';
province.value = c.province ?? '';
city.value = c.city ?? '';
postalCode.value = c.postalCode ?? '';
address.value = c.address ?? '';
groupName.value = c.groupName ?? '';
registeredAt.value = c.registeredAt ? formatLocalDateInput(new Date(c.registeredAt)) : '';
notes.value = c.notes ?? '';
tagsRaw.value = (c.tags ?? []).join('، ');
customFields.value = { ...(c.customFields ?? {}) };
@@ -88,11 +116,24 @@ async function load() {
function payload(): CreateCompanyRequest {
return {
name: name.value,
code: code.value || null,
sourceRef: sourceRef.value || null,
...(sourceRef.value && importSource.value === 'manual' ? { importSource: 'didar' } : {}),
nationalId: nationalId.value || undefined,
economicCode: economicCode.value || null,
registrationNumber: registrationNumber.value || null,
phone: phone.value || undefined,
otherPhones: otherPhones.value || null,
fax: fax.value || null,
email: email.value || undefined,
otherEmails: otherEmails.value || null,
website: website.value || undefined,
province: province.value || null,
city: city.value || null,
postalCode: postalCode.value || null,
address: address.value || undefined,
groupName: groupName.value || null,
registeredAt: parseLocalInput(registeredAt.value)?.toISOString() ?? null,
notes: notes.value || undefined,
tags: parseTags(tagsRaw.value),
customFields: customFields.value,
@@ -204,50 +245,20 @@ onMounted(load);
/>
</UiField>
<UiField label="شناسه ملی">
<UiInput
id="nationalId"
v-model="nationalId"
placeholder="شناسه ملی ۱۱ رقمی"
dir="ltr"
/>
<UiField label="کد شرکت">
<UiInput id="code" v-model="code" placeholder="کد داخلی" dir="ltr" />
</UiField>
<UiField label="شماره تلفن شرکت">
<UiInput
id="phone"
v-model="phone"
type="tel"
placeholder="۰۲۱..."
dir="ltr"
/>
<UiField label="کد دیدار شرکت">
<UiInput id="sourceRef" v-model="sourceRef" placeholder="مثال: 5277" dir="ltr" />
</UiField>
<UiField label="پست الکترونیکی (ایمیل)">
<UiInput
id="email"
v-model="email"
type="email"
placeholder="info@company.ir"
dir="ltr"
/>
<UiField label="گروه بندی شرکت">
<UiInput id="groupName" v-model="groupName" placeholder="مثال: همکار، عمده‌فروش" />
</UiField>
<UiField label="وب‌سایت">
<UiInput
id="website"
v-model="website"
placeholder="https://..."
dir="ltr"
/>
</UiField>
<UiField label="آدرس پستی" class="col-span-2">
<UiInput
id="address"
v-model="address"
placeholder="نشانی، طبقه، پلاک..."
/>
<UiField label="تاریخ ثبت شرکت">
<JalaliDateInput id="registeredAt" v-model="registeredAt" />
</UiField>
<UiField label="برچسب‌ها" class="col-span-2">
@@ -258,7 +269,7 @@ onMounted(load);
/>
</UiField>
<UiField label="یادداشت و توضیحات تکمیلی" class="col-span-2">
<UiField label="توضیحات شرکت" class="col-span-2">
<UiTextarea
id="notes"
v-model="notes"
@@ -269,6 +280,72 @@ onMounted(load);
</div>
</UiCard>
<UiCard title="اطلاعات تماس و آدرس" class="section-card">
<div class="crm-form-grid-2">
<UiField label="تلفن شرکت">
<UiInput id="phone" v-model="phone" type="tel" placeholder="۰۲۱..." dir="ltr" />
</UiField>
<UiField label="تلفن های دیگر شرکت">
<UiInput id="otherPhones" v-model="otherPhones" placeholder="با کاما جدا کنید" dir="ltr" />
</UiField>
<UiField label="ایمیل شرکت">
<UiInput
id="email"
v-model="email"
type="email"
placeholder="info@company.ir"
dir="ltr"
/>
</UiField>
<UiField label="ایمیل های دیگر شرکت">
<UiInput id="otherEmails" v-model="otherEmails" placeholder="با کاما جدا کنید" dir="ltr" />
</UiField>
<UiField label="شماره فکس">
<UiInput id="fax" v-model="fax" type="tel" dir="ltr" />
</UiField>
<UiField label="سایت های شرکت">
<UiInput id="website" v-model="website" placeholder="https://..." dir="ltr" />
</UiField>
<UiField label="استان">
<UiInput id="province" v-model="province" placeholder="مثال: تهران" />
</UiField>
<UiField label="شهرستان">
<UiInput id="city" v-model="city" placeholder="مثال: تهران" />
</UiField>
<UiField label="کد پستی">
<UiInput id="postalCode" v-model="postalCode" dir="ltr" />
</UiField>
<UiField label="آدرس" class="col-span-2">
<UiInput id="address" v-model="address" placeholder="نشانی، طبقه، پلاک..." />
</UiField>
</div>
</UiCard>
<UiCard title="اطلاعات حقوقی و ثبتی" class="section-card">
<div class="crm-form-grid-2">
<UiField label="شناسه ملی">
<UiInput id="nationalId" v-model="nationalId" placeholder="شناسه ملی ۱۱ رقمی" dir="ltr" />
</UiField>
<UiField label="شماره اقتصادی">
<UiInput id="economicCode" v-model="economicCode" dir="ltr" />
</UiField>
<UiField label="شماره ثبت">
<UiInput id="registrationNumber" v-model="registrationNumber" dir="ltr" />
</UiField>
</div>
</UiCard>
<CustomFieldsSection entity-type="company" v-model="customFields" />
<EntityTimeline v-if="!isNew && id" entity-type="company" :entity-id="id" />
+35 -5
View File
@@ -733,7 +733,17 @@ onMounted(load);
<UiIcon name="mail" :size="13" />
<span>{{ c.email }}</span>
</p>
<p class="role">افراد: {{ toFaDigits(c.peopleCount) }}</p>
<p v-if="c.sourceRef || c.code" class="line">
<span>
{{ c.sourceRef ? `کد دیدار: ${toFaDigits(c.sourceRef)}` : '' }}
{{ c.sourceRef && c.code ? ' • ' : '' }}
{{ c.code ? `کد: ${toFaDigits(c.code)}` : '' }}
</span>
</p>
<p class="role">
{{ [c.city, c.owner?.fullName].filter(Boolean).join(' • ') }}
{{ c.city || c.owner ? ' • ' : '' }}افراد: {{ toFaDigits(c.peopleCount) }}
</p>
</div>
</article>
</div>
@@ -743,8 +753,18 @@ onMounted(load);
<tr>
<th class="num-col">#</th>
<th>نام شرکت</th>
<th>تلفن</th>
<th>ایمیل</th>
<th>کد دیدار شرکت</th>
<th>کد شرکت</th>
<th>منبع</th>
<th>گروه بندی</th>
<th>استان</th>
<th>شهرستان</th>
<th>تلفن شرکت</th>
<th>ایمیل شرکت</th>
<th>شناسه ملی</th>
<th>شماره اقتصادی</th>
<th>مسئول شرکت</th>
<th>تاریخ ثبت</th>
<th>افراد</th>
</tr>
</thead>
@@ -756,8 +776,18 @@ onMounted(load);
{{ c.name }}
</RouterLink>
</td>
<td>{{ c.phone || '—' }}</td>
<td>{{ c.email || '—' }}</td>
<td dir="ltr">{{ c.sourceRef ? toFaDigits(c.sourceRef) : '—' }}</td>
<td dir="ltr">{{ c.code ? toFaDigits(c.code) : '—' }}</td>
<td>{{ sourceLabel(c.importSource || 'manual') }}</td>
<td>{{ c.groupName || '—' }}</td>
<td>{{ c.province || '—' }}</td>
<td>{{ c.city || '—' }}</td>
<td dir="ltr">{{ c.phone || '—' }}</td>
<td><span class="mail">{{ c.email || '—' }}</span></td>
<td dir="ltr">{{ c.nationalId || '—' }}</td>
<td dir="ltr">{{ c.economicCode || '—' }}</td>
<td>{{ c.owner?.fullName || '—' }}</td>
<td>{{ formatDate(c.registeredAt || c.createdAt) }}</td>
<td>{{ toFaDigits(c.peopleCount) }}</td>
</tr>
</tbody>
+28
View File
@@ -664,12 +664,27 @@ export interface CompanySummaryDto {
export interface CompanyDto {
id: string;
name: string;
/** کد داخلی شرکت */
code: string | null;
importSource: PersonImportSource | string;
/** کد شرکت در مبدأ (مثلاً کد دیدار) */
sourceRef: string | null;
nationalId: string | null;
economicCode: string | null;
registrationNumber: string | null;
phone: string | null;
otherPhones: string | null;
fax: string | null;
email: string | null;
otherEmails: string | null;
website: string | null;
province: string | null;
city: string | null;
postalCode: string | null;
address: string | null;
groupName: string | null;
notes: string | null;
registeredAt: string | null;
tags: string[];
customFields: Record<string, unknown>;
peopleCount: number;
@@ -680,12 +695,25 @@ export interface CompanyDto {
export interface CreateCompanyRequest {
name: string;
code?: string | null;
importSource?: PersonImportSource | string;
sourceRef?: string | null;
nationalId?: string;
economicCode?: string | null;
registrationNumber?: string | null;
phone?: string;
otherPhones?: string | null;
fax?: string | null;
email?: string;
otherEmails?: string | null;
website?: string;
province?: string | null;
city?: string | null;
postalCode?: string | null;
address?: string;
groupName?: string | null;
notes?: string;
registeredAt?: string | null;
tags?: string[];
customFields?: Record<string, unknown>;
ownerId?: string | null;