merge: bring latest origin/main into dev (keep local UI)

Resolved UI conflicts in favor of local Ant Design screens; kept non-UI changes from main.
This commit is contained in:
2026-11-03 16:55:42 +03:30
201 changed files with 19202 additions and 1607 deletions
+687 -3
View File
@@ -1,3 +1,15 @@
import type { PersonGender } from "./person-name";
export type { PersonGender } from "./person-name";
export {
PERSON_GENDERS,
PERSON_GENDER_LABELS,
applyLeadGenderName,
applyPersonGenderTitle,
isGenderQuestionLabel,
parsePersonGenderAnswer,
splitPersonHonorific,
} from "./person-name";
export type UserRole = "owner" | "admin" | "sales" | "support";
export const USER_ROLES: UserRole[] = ["owner", "admin", "sales", "support"];
@@ -42,6 +54,8 @@ export interface AuthUserDto {
enabledModules?: OrgModuleKey[];
/** خلاصه اشتراک مالک محیط (برای اعضا فقط جهت نمایش/گیت ورود) */
subscription?: SubscriptionSummaryDto | null;
/** حجم فایل اشتراک مالک همین محیط */
storage?: StorageUsageDto | null;
/** اگر باشد، این کاربر سوئیچ «بایگانی اطلاعات قبل» را دارد */
priorArchive?: { lens: "current" | "archive"; cutoffAt: string | null } | null;
/**
@@ -226,7 +240,7 @@ export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
loyalty: "باشگاه مشتریان",
reports: "گزارش‌ها",
notes: "یادداشت‌ها",
tahesab: "ته‌حساب",
tahesab: "ته‌حساب (اتصال و گزارش فروش)",
custom_fields: "فیلدهای سفارشی",
currencies: "ارزها",
team_settings: "تیم و تنظیمات سازمان",
@@ -321,6 +335,34 @@ export function defaultEnabledModules(): OrgModuleKey[] {
return ORG_MODULE_KEYS.filter((k) => !OPT_IN_ORG_MODULES.includes(k));
}
export type StorageUsageLevel = "ok" | "warn" | "full" | "unlimited";
/** حجم فایل اشتراک مالک. quotaBytes خالی یعنی سقفی گذاشته نشده. */
export interface StorageUsageDto {
usedBytes: number;
quotaBytes: number | null;
percent: number | null;
level: StorageUsageLevel;
byOrganization?: Array<{ id: string; name: string; usedBytes: number }>;
}
export interface SetStorageQuotaRequest {
/** گیگابایت؛ null یعنی بدون سقف */
quotaGb: number | null;
}
/** سقف‌های آمادهٔ تعداد کاربر. بالاتر از ۲۰ توافقی و با عدد جدا ذخیره می‌شود. */
export const BILLING_USER_CAPS = [3, 10, 20] as const;
export interface OwnerSeatUsageDto {
usedUsers: number;
maxUsers: number | null;
}
export interface SetUserCapRequest {
maxUsers: number;
}
export interface SubscriptionSummaryDto {
planCode: BillingPlanCode;
status: BillingAccountStatus;
@@ -339,10 +381,13 @@ export interface BillingAccountDto {
startsAt: string;
expiresAt: string;
maxOrganizations: number;
/** null یعنی سقف کاربر هنوز تعیین نشده */
maxUsers?: number | null;
externalPaymentRef?: string | null;
notes?: string | null;
daysRemaining: number;
warn: boolean;
storage?: StorageUsageDto | null;
}
export interface ApplyBillingPlanRequest {
@@ -374,13 +419,33 @@ export interface OrgUserDto {
dataVisibility?: DataVisibility;
/** سطح دسترسی تفکیکی هر بخش (فقط بخش‌هایی که با پیش‌فرض فرق دارند) */
dataVisibilityScopes?: DataVisibilityScopes;
/**
* Override عمق داده نسبت به نقش؛ کلید غایب = ارث از نقش.
* اگر کل آبجکت خالی باشد و نقش هم خالی باشد → بدون محدودیت عمقی.
*/
dataAccessOverride?: DataAccessPolicy;
/** سیاست مؤثر پس از resolve (نقش + override) — فقط برای نمایش UI */
effectiveDataAccess?: DataAccessPolicy;
/** @deprecated از effectiveDataAccess.customers استفاده کنید */
effectiveCustomersAccess?: SectionDataAccess;
/** تیک مالک: این کاربر سوئیچ بایگانی اطلاعات قبل را ببیند */
priorArchiveEnabled?: boolean;
/** ساعت خروج روزانه HH:mm به وقت تهران؛ خالی یعنی بدون محدودیت */
logoutTime?: string | null;
/** بعد از ساعت خروج تا پایان روز نمی‌تواند دوباره وارد شود */
blockLoginAfterLogout?: boolean;
/**
* ورود فقط با کد پیامکی؛ رمز عبور برای این کاربر در این محیط پذیرفته نمی‌شود
* (حتی اگر از قبل رمز داشته باشد).
*/
loginOtpOnly?: boolean;
createdAt: string;
}
export interface UpdateUserLoginOtpOnlyRequest {
loginOtpOnly: boolean;
}
export type DataVisibility = "own" | "team" | "org";
export const DATA_VISIBILITIES: DataVisibility[] = ["own", "team", "org"];
@@ -419,7 +484,7 @@ export const DATA_SCOPE_LABELS: Record<DataScope, string> = {
contacts: "مشتریان (اشخاص و شرکت‌ها)",
deals: "معاملات و اهداف فروش",
activities: "فعالیت‌ها و کارها",
leads: "سرنخ‌ها",
leads: "لیدها",
finance: "فاکتور، پیش‌فاکتور و چک",
tickets: "تیکت‌ها",
notes: "یادداشت‌ها",
@@ -436,12 +501,240 @@ export interface UpdateUserVisibilityRequest {
scopes?: Partial<Record<DataScope, DataVisibility | null>>;
}
/* ——— Deep data access (customers + leads + sales + activities + notes/routines/projects/tickets) ——— */
export type DataAccessOwnerKind = "self" | "team";
export type DataAccessMode = "all" | "rules";
/**
* بخش‌های عمق داده.
* customers/leads/sales/activities/notes ≈ RolePermissionKey؛
* routines/projects/tickets ≈ ماژول / DataScope (بدون مجوز نقش جدا).
*/
export type DataAccessSection =
| "customers"
| "leads"
| "sales"
| "activities"
| "notes"
| "routines"
| "projects"
| "tickets";
export const DATA_ACCESS_SECTIONS: DataAccessSection[] = [
"customers",
"leads",
"sales",
"activities",
"notes",
"routines",
"projects",
"tickets",
];
export const DATA_ACCESS_SECTION_LABELS: Record<DataAccessSection, string> = {
customers: "مشتریان (اشخاص و شرکت‌ها)",
leads: "لیدها",
sales: "فروش و معاملات",
activities: "فعالیت‌ها",
notes: "یادداشت‌ها",
routines: "کارهای روتین",
projects: "پروژه‌ها",
tickets: "تیکت‌ها",
};
/** نگاشت بخش عمق‌داده ← کلید قدیمی DataScope برای مهاجرت */
export const DATA_ACCESS_LEGACY_SCOPE: Record<DataAccessSection, DataScope | null> = {
customers: "contacts",
leads: "leads",
sales: "deals",
activities: "activities",
notes: "notes",
routines: "routines",
projects: "projects",
tickets: "tickets",
};
/**
* یک قانون: فیلدهای پرشده با AND.
* قوانین متعدد در SectionDataAccess با OR ترکیب می‌شوند.
* ابعاد بلااستفاده برای یک بخش نادیده گرفته می‌شوند.
*/
export type DataAccessRule = {
/** self / team → مالک / مسئول / نویسنده (بسته به بخش) */
owners?: DataAccessOwnerKind[];
/** مشتریان: گروه‌ها */
groupIds?: string[];
/** مشتریان: importSource/sourceRef · لیدها: source · یادداشت: noteType · تیکت: status */
sources?: string[];
/** مشتریان: حال */
moods?: PersonMood[];
/** لید / معامله / فعالیت / پروژه: مرحله */
stageIds?: string[];
/** معامله: پایپ‌لاین */
pipelineIds?: string[];
/** فعالیت / پروژه: بورد */
boardIds?: string[];
/** فعالیت: typeDefId · روتین: categoryId */
typeIds?: string[];
};
/** @deprecated نام قبلی؛ از DataAccessRule استفاده کنید */
export type ContactAccessRule = DataAccessRule;
export type SectionDataAccess = {
mode: DataAccessMode;
rules?: DataAccessRule[];
};
export type DataAccessPolicy = Partial<Record<DataAccessSection, SectionDataAccess>>;
export interface UpdateUserDataAccessRequest {
/**
* null یعنی حذف override و برگشت به ارث از نقش.
* آبجکت یعنی جایگزینی کامل override (کلید غایب در آبجکت = آن بخش از override پاک می‌شود).
*/
dataAccessOverride?: DataAccessPolicy | null;
}
export function emptySectionDataAccess(): SectionDataAccess {
return { mode: "all", rules: [] };
}
export function parseDataAccessPolicy(raw: unknown): DataAccessPolicy {
const out: DataAccessPolicy = {};
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
const obj = raw as Record<string, unknown>;
for (const section of DATA_ACCESS_SECTIONS) {
const parsed = parseSectionDataAccess(obj[section]);
if (parsed) out[section] = parsed;
}
return out;
}
export function parseSectionDataAccess(raw: unknown): SectionDataAccess | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const obj = raw as Record<string, unknown>;
const mode: DataAccessMode = obj.mode === "rules" ? "rules" : "all";
const rulesRaw = Array.isArray(obj.rules) ? obj.rules : [];
const rules: DataAccessRule[] = [];
for (const item of rulesRaw) {
const rule = parseDataAccessRule(item);
if (rule) rules.push(rule);
}
if (mode === "all") return { mode: "all", rules: [] };
return { mode: "rules", rules };
}
function parseStringIdList(raw: unknown): string[] | undefined {
if (!Array.isArray(raw)) return undefined;
const list = raw
.filter((v): v is string => typeof v === "string" && v.trim().length > 0)
.map((v) => v.trim());
return list.length ? [...new Set(list)] : undefined;
}
export function parseDataAccessRule(raw: unknown): DataAccessRule | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const obj = raw as Record<string, unknown>;
const rule: DataAccessRule = {};
if (Array.isArray(obj.owners)) {
const owners = obj.owners.filter(
(v): v is DataAccessOwnerKind => v === "self" || v === "team",
);
if (owners.length) rule.owners = [...new Set(owners)];
}
const groupIds = parseStringIdList(obj.groupIds);
if (groupIds) rule.groupIds = groupIds;
const sources = parseStringIdList(obj.sources);
if (sources) rule.sources = sources;
if (Array.isArray(obj.moods)) {
const moods = obj.moods.filter(
(v): v is PersonMood =>
typeof v === "string" && (PERSON_MOODS as readonly string[]).includes(v),
);
if (moods.length) rule.moods = [...new Set(moods)];
}
const stageIds = parseStringIdList(obj.stageIds);
if (stageIds) rule.stageIds = stageIds;
const pipelineIds = parseStringIdList(obj.pipelineIds);
if (pipelineIds) rule.pipelineIds = pipelineIds;
const boardIds = parseStringIdList(obj.boardIds);
if (boardIds) rule.boardIds = boardIds;
const typeIds = parseStringIdList(obj.typeIds);
if (typeIds) rule.typeIds = typeIds;
if (
!rule.owners &&
!rule.groupIds &&
!rule.sources &&
!rule.moods &&
!rule.stageIds &&
!rule.pipelineIds &&
!rule.boardIds &&
!rule.typeIds
) {
return null;
}
return rule;
}
/** @deprecated */
export const parseContactAccessRule = parseDataAccessRule;
/** آیا این بخش محدودیت عمقی دارد؟ mode=all یا بدون قانون معتبر = بدون محدودیت */
export function sectionHasDataAccessLimits(section: SectionDataAccess | null | undefined): boolean {
if (!section || section.mode !== "rules") return false;
return Boolean(section.rules?.length);
}
/**
* مهاجرت از مدل قدیمی own/team/org به SectionDataAccess.
* اگر scopeKey داده شود، سطح تفکیکی همان بخش اولویت دارد.
*/
export function migrateVisibilityToSectionAccess(
dataVisibility: DataVisibility | string | null | undefined,
scopes?: DataVisibilityScopes | null,
scopeKey?: DataScope | null,
): SectionDataAccess {
const scoped =
scopeKey && scopes
? scopes[scopeKey]
: undefined;
const level =
scoped === "own" || scoped === "team" || scoped === "org"
? scoped
: dataVisibility === "team" || dataVisibility === "org" || dataVisibility === "own"
? dataVisibility
: "own";
if (level === "org") return { mode: "all", rules: [] };
if (level === "team") return { mode: "rules", rules: [{ owners: ["self", "team"] }] };
return { mode: "rules", rules: [{ owners: ["self"] }] };
}
/** @deprecated از migrateVisibilityToSectionAccess استفاده کنید */
export function migrateVisibilityToCustomersAccess(
dataVisibility: DataVisibility | string | null | undefined,
scopes?: DataVisibilityScopes | null,
): SectionDataAccess {
return migrateVisibilityToSectionAccess(dataVisibility, scopes, "contacts");
}
export type RolePermissionKey =
| "customers"
| "assign_customers"
| "leads"
| "assign_leads"
| "sales"
| "assign_sales"
| "billing"
| "products"
| "activities"
| "assign_activities"
| "assign_routines"
| "assign_projects"
| "assign_tickets"
| "notes"
| "reports"
| "audit"
@@ -451,15 +744,43 @@ export type RolePermissionKey =
| "treasury_payroll"
| "behavior";
/** بخشی که مسئولش را می‌شود به کاربر دیگری داد */
export type AssignSection =
| "customers"
| "leads"
| "sales"
| "activities"
| "routines"
| "projects"
| "tickets";
export const ASSIGN_SECTION_PERMISSION: Record<AssignSection, RolePermissionKey> = {
customers: "assign_customers",
leads: "assign_leads",
sales: "assign_sales",
activities: "assign_activities",
routines: "assign_routines",
projects: "assign_projects",
tickets: "assign_tickets",
};
export const ROLE_PERMISSION_ITEMS: Array<{
key: RolePermissionKey;
label: string;
}> = [
{ key: "customers", label: "مشتریان" },
{ key: "assign_customers", label: "تخصیص مشتری به دیگران" },
{ key: "leads", label: "لیدها" },
{ key: "assign_leads", label: "تخصیص لید به دیگران" },
{ key: "sales", label: "فروش و معاملات" },
{ key: "assign_sales", label: "تخصیص معامله به دیگران" },
{ key: "billing", label: "پیش‌فاکتورها و فاکتورها" },
{ key: "products", label: "محصولات و خدمات" },
{ key: "activities", label: "فعالیت‌ها" },
{ key: "assign_activities", label: "تخصیص فعالیت به دیگران" },
{ key: "assign_routines", label: "تخصیص کار روتین به دیگران" },
{ key: "assign_projects", label: "تخصیص کارت پروژه به دیگران" },
{ key: "assign_tickets", label: "تخصیص تیکت به دیگران" },
{ key: "notes", label: "یادداشت‌ها" },
{ key: "reports", label: "گزارش‌ها" },
{ key: "audit", label: "لاگ فعالیت کاربران" },
@@ -482,6 +803,8 @@ export interface OrgRoleDto {
description: string;
color: string;
permissions: RolePermissionKey[];
/** پیش‌فرض عمق داده برای اعضای این نقش */
dataAccessPolicy?: DataAccessPolicy;
systemKey: UserRole | null;
/** نقش والد؛ null یعنی ریشه */
parentId: string | null;
@@ -500,6 +823,7 @@ export interface CreateOrgRoleRequest {
description?: string;
color?: string;
permissions?: RolePermissionKey[];
dataAccessPolicy?: DataAccessPolicy;
/** اگر ست شود، این نقش زیرمجموعهٔ نقش والد خواهد بود */
parentId?: string | null;
}
@@ -509,6 +833,7 @@ export interface UpdateOrgRoleRequest {
description?: string;
color?: string;
permissions?: RolePermissionKey[];
dataAccessPolicy?: DataAccessPolicy;
/** null یعنی جدا کردن از والد و تبدیل به ریشه */
parentId?: string | null;
}
@@ -770,6 +1095,53 @@ export interface HealthResponse {
timestamp: string;
}
/* ───────── گروه مشتری (اشخاص و شرکت‌ها) ───────── */
export const CUSTOMER_GROUP_COLORS = [
"teal",
"sky",
"violet",
"rose",
"amber",
"pink",
"slate",
] as const;
export type CustomerGroupColor = (typeof CUSTOMER_GROUP_COLORS)[number];
export interface CustomerGroupSummaryDto {
id: string;
name: string;
color: CustomerGroupColor | string;
/** manual | tahesab — فقط برای اشخاص */
source?: "manual" | "tahesab";
}
export interface CustomerGroupDto {
id: string;
name: string;
color: CustomerGroupColor | string;
position: number;
/** گروه متناظر در ته‌حساب دارد */
fromTahesab: boolean;
personCount: number;
companyCount: number;
/** گروه اصلی؛ خالی یعنی خودش اصلی است */
parentId?: string | null;
parentName?: string | null;
}
export interface CreateCustomerGroupRequest {
name: string;
color?: CustomerGroupColor;
}
export interface UpdateCustomerGroupRequest {
name?: string;
color?: CustomerGroupColor;
/** null = برداشتن گروه اصلی */
parentId?: string | null;
}
export interface CompanySummaryDto {
id: string;
name: string;
@@ -803,6 +1175,7 @@ export interface CompanyDto {
registeredAt: string | null;
tags: string[];
customFields: Record<string, unknown>;
groups: CustomerGroupSummaryDto[];
peopleCount: number;
owner: UserSummaryDto | null;
createdAt: string;
@@ -833,6 +1206,8 @@ export interface CreateCompanyRequest {
tags?: string[];
customFields?: Record<string, unknown>;
ownerId?: string | null;
/** همهٔ گروه‌های شرکت (جایگزین کامل) */
groupIds?: string[];
}
export type UpdateCompanyRequest = Partial<CreateCompanyRequest>;
@@ -897,6 +1272,8 @@ export interface PersonDto {
previousImportSource: PersonImportSource | string | null;
previousSourceRef: string | null;
fullName: string;
gender: PersonGender;
showGenderInName: boolean;
email: string | null;
phone: string | null;
mobile: string | null;
@@ -910,6 +1287,7 @@ export interface PersonDto {
mood: PersonMood;
tags: string[];
customFields: Record<string, unknown>;
groups: CustomerGroupSummaryDto[];
loyaltyPoints: number;
loyaltyTier: LoyaltyTierSummaryDto | null;
/** تیپ غالب یا ترکیبی تأییدشده؛ بدون مجوز رفتار خالی برمی‌گردد */
@@ -925,6 +1303,8 @@ export interface PersonDto {
export interface CreatePersonRequest {
firstName?: string;
lastName?: string;
gender?: PersonGender;
showGenderInName?: boolean;
previousFirstName?: string | null;
previousLastName?: string | null;
importSource?: PersonImportSource | string;
@@ -946,6 +1326,8 @@ export interface CreatePersonRequest {
ownerId?: string | null;
/** فقط برای ایمپورت اکسل — تاریخ ثبت مبدأ */
registeredAt?: string | null;
/** همهٔ گروه‌های شخص (جایگزین کامل؛ منبع گروه‌های ته‌حسابیِ باقی‌مانده حفظ می‌شود) */
groupIds?: string[];
}
export type UpdatePersonRequest = Partial<CreatePersonRequest>;
@@ -1285,14 +1667,18 @@ export interface ActivityBoardDto {
name: string;
isDefault: boolean;
position: number;
/** خالی = گروه برای همهٔ اعضای محیط باز است */
memberIds: string[];
}
export interface CreateActivityBoardRequest {
name: string;
memberIds?: string[];
}
export interface UpdateActivityBoardRequest {
name?: string;
memberIds?: string[];
}
export interface ActivityStageDto {
@@ -1478,7 +1864,7 @@ export interface ReorderActivityTypesRequest {
typeIds: string[];
}
export type CustomFieldEntity = "person" | "company" | "deal";
export type CustomFieldEntity = "person" | "company" | "deal" | "note";
export type CustomFieldType = "text" | "number" | "date" | "boolean" | "select";
export interface CustomFieldDefinitionDto {
@@ -1520,6 +1906,14 @@ export interface DealSummaryDto {
title: string;
}
export interface ActivityImageDto {
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
createdAt: string;
}
export interface ActivityDto {
id: string;
type: ActivityType;
@@ -1527,6 +1921,8 @@ export interface ActivityDto {
priority: ActivityPriority;
title: string;
description: string | null;
/** عکس‌های یادداشت؛ فقط در جزئیات فعالیت پر می‌شود */
images?: ActivityImageDto[];
dueAt: string | null;
durationMinutes: number | null;
reminderAt: string | null;
@@ -1586,6 +1982,8 @@ export interface CreateActivityRequest {
personId?: string | null;
companyId?: string | null;
dealId?: string | null;
/** لید مرتبط؛ فعالیت در پروندهٔ همان لید می‌ماند */
leadId?: string | null;
/** سازگاری — اگر assigneeIds نباشد از این استفاده می‌شود */
assigneeId?: string | null;
/** چند مسئول؛ اولی به‌عنوان assigneeId ذخیره می‌شود */
@@ -1611,6 +2009,7 @@ export interface UpdateActivityRequest {
personId?: string | null;
companyId?: string | null;
dealId?: string | null;
leadId?: string | null;
assigneeId?: string | null;
assigneeIds?: string[];
}
@@ -1663,6 +2062,7 @@ export interface NoteDto {
checklist: NoteChecklistItemDto[];
locationText: string | null;
analyzeEnabled: boolean;
customFields: Record<string, unknown>;
person: PersonSummaryDto | null;
company: CompanySummaryDto | null;
deal: DealSummaryDto | null;
@@ -1684,6 +2084,43 @@ export const NOTE_VISIBILITY_LABELS: Record<NoteVisibility, string> = {
personal: "شخصی (فقط من)",
};
export type NoteCategoryTone =
| "general"
| "follow_up"
| "idea"
| "suggestion"
| "meeting"
| "personal";
export interface NoteCategoryDto {
id: string;
key: string;
label: string;
tone: NoteCategoryTone | string;
position: number;
isActive: boolean;
showInBar: boolean;
isSystem: boolean;
}
export interface CreateNoteCategoryRequest {
label: string;
tone?: NoteCategoryTone | string;
showInBar?: boolean;
}
export interface UpdateNoteCategoryRequest {
label?: string;
tone?: NoteCategoryTone | string;
showInBar?: boolean;
isActive?: boolean;
position?: number;
}
export interface ReorderNoteCategoriesRequest {
ids: string[];
}
export interface NoteRevisionDto {
id: string;
title: string | null;
@@ -1712,6 +2149,7 @@ export interface CreateNoteRequest {
companyId?: string | null;
dealId?: string | null;
activityId?: string | null;
customFields?: Record<string, unknown>;
}
export interface UpdateNoteRequest {
@@ -1730,6 +2168,7 @@ export interface UpdateNoteRequest {
companyId?: string | null;
dealId?: string | null;
activityId?: string | null;
customFields?: Record<string, unknown>;
}
export type NoteAiSuggestionStatus =
@@ -2106,6 +2545,8 @@ export type LeadFormMapTo =
| "lead.trade"
| "lead.notes"
| "lead.source"
| "lead.gender"
| "person.gender"
| "notes"
| "custom";
@@ -3809,6 +4250,224 @@ export interface PersonAccountingLinkDto {
isDefaultConnection?: boolean;
}
/** طبقه‌بندی تقریبی نوع سند از روی متن NO ته‌حساب */
export const TAHESAB_ASNAD_KINDS = [
"sale",
"return",
"purchase",
"other",
] as const;
export type TahesabAsnadKind = (typeof TAHESAB_ASNAD_KINDS)[number];
export const TAHESAB_ASNAD_KIND_LABELS: Record<TahesabAsnadKind, string> = {
sale: "فروش",
return: "مرجوعی",
purchase: "خرید",
other: "سایر",
};
export const TAHESAB_JENS_FELEZ_LABELS: Record<number, string> = {
0: "طلا",
1: "پلاتین",
2: "نقره",
};
export interface TahesabAsnadSyncRequest {
connectionId?: string;
/** شمسی YYYY-MM-DD */
from: string;
/** شمسی YYYY-MM-DD */
to: string;
/** محدود کردن همگام‌سازی به یک شخص */
personId?: string;
/** محدود کردن همگام‌سازی به گروه‌ها و زیرگروه‌ها */
groupIds?: string[];
}
export interface TahesabAsnadSyncJobDto {
id: string;
status: "pending" | "running" | "done" | "failed" | string;
connectionId: string | null;
from: string;
to: string;
processed: number;
total: number;
linesUpserted: number;
truncatedPersonIds: string[];
errorMessage: string | null;
createdAt: string;
updatedAt: string;
}
export interface TahesabAsnadLineDto {
id: string;
connectionId: string;
personId: string | null;
personName: string | null;
moshtariCode: number;
tarikh: string;
noRaw: string;
kind: TahesabAsnadKind | string;
jensFelez: number | null;
noKar: string | null;
vazn: number | null;
tabdilVazn: number | null;
mali: number | null;
count: number | null;
shFactor: number | null;
factorCode: string | null;
groups: CustomerGroupSummaryDto[];
syncedAt: string;
}
export interface TahesabAsnadReportQuery {
connectionId?: string;
from: string;
to: string;
groupIds?: string[];
personId?: string;
kinds?: TahesabAsnadKind[];
jensFelez?: number;
page?: number;
pageSize?: number;
}
export interface TahesabAsnadReportPageDto {
items: TahesabAsnadLineDto[];
total: number;
page: number;
pageSize: number;
}
export interface TahesabAsnadSummaryBucketDto {
key: string;
label: string;
lineCount: number;
maliSum: number;
vaznSum: number;
}
export interface TahesabAsnadSummaryDto {
totalLines: number;
maliSum: number;
vaznSum: number;
byKind: TahesabAsnadSummaryBucketDto[];
byMetal: TahesabAsnadSummaryBucketDto[];
byGroup: TahesabAsnadSummaryBucketDto[];
distinctNos: string[];
lastSyncedAt: string | null;
}
export interface SalesPercentStatsDto {
first: number | null;
last: number | null;
min: number | null;
max: number | null;
avg: number | null;
}
export interface SalesMovementStatsDto {
grossWeight: number;
returnWeight: number;
netWeight: number;
grossMali: number;
returnMali: number;
netMali: number;
grossCount: number;
returnCount: number;
netCount: number;
percent: SalesPercentStatsDto | null;
}
export interface SalesFactorOverviewDto {
key: string;
label: string;
sale: SalesMovementStatsDto;
purchase: SalesMovementStatsDto;
}
export interface SalesNamedMovementDto {
name: string;
sale: SalesMovementStatsDto;
purchase: SalesMovementStatsDto;
}
export interface SalesGroupChildDto {
id: string;
label: string;
netSaleWeight: number;
netSaleMali: number;
saleReturnWeight: number;
}
export interface SalesGroupOverviewDto {
id: string;
label: string;
netSaleWeight: number;
netSaleMali: number;
saleReturnWeight: number;
children: SalesGroupChildDto[];
}
export interface SalesExpertOverviewDto {
userId: string;
name: string;
percent: number;
silverWeight: number;
silverMali: number;
silverCommission: number;
investWeight: number;
investMali: number;
investCommission: number;
}
export interface SalesInternetOverviewDto {
orderCount: number;
returnCount: number;
netGold: number;
netSilver: number;
returnGold: number;
returnSilver: number;
netMali: number;
returnMali: number;
linkedSkipped: number;
}
export interface TahesabSalesOverviewDto {
factors: SalesFactorOverviewDto[];
karsakhte: SalesNamedMovementDto[];
groups: SalesGroupOverviewDto[];
experts: SalesExpertOverviewDto[];
internet: SalesInternetOverviewDto;
distinctNos: string[];
otherNos: string[];
percentReady: boolean;
}
/** تنظیمات ظاهر فاکتور PDF اسناد ته‌حساب (جدا از فاکتور CRM) */
export interface TahesabInvoiceSettingsDto {
id: string;
organizationId: string;
logoDataUrl: string | null;
companyName: string;
companySubtitle: string;
address: string;
phone: string;
headerText: string;
footerText: string;
updatedAt: string;
}
export interface UpdateTahesabInvoiceSettingsRequest {
logoDataUrl?: string | null;
companyName?: string;
companySubtitle?: string;
address?: string;
phone?: string;
headerText?: string;
footerText?: string;
}
export interface MandehValueDto {
amount: string | number;
unit: string;
@@ -4037,6 +4696,22 @@ export interface ResetPlatformAdminPasswordRequest {
password: string;
}
export interface UpdatePlatformAdminRequest {
fullName: string;
mobile: string;
email?: string | null;
}
export interface PlatformMasterPasswordStatusDto {
enabled: boolean;
updatedAt?: string | null;
}
export interface SetPlatformMasterPasswordRequest {
/** null یعنی رمز کلی برداشته شود */
password: string | null;
}
export interface PlatformStatsDto {
organizationCount: number;
activeOrganizationCount: number;
@@ -4073,6 +4748,9 @@ export interface PlatformOrganizationDto {
enabledModules?: OrgModuleKey[];
/** اشتراک مالک اصلی محیط */
subscription?: SubscriptionSummaryDto | null;
ownerUserId?: string | null;
ownerName?: string | null;
chatConfigured?: boolean;
members?: PlatformOrgMemberDto[];
}
@@ -4972,6 +5650,8 @@ export interface LeadDto {
id: string;
title: string;
contactName: string | null;
gender: PersonGender;
showGenderInName: boolean;
mobile: string;
phone: string | null;
city: string | null;
@@ -5012,6 +5692,8 @@ export interface CreateLeadRequest {
title: string;
mobile: string;
contactName?: string | null;
gender?: PersonGender;
showGenderInName?: boolean;
phone?: string | null;
city?: string | null;
region?: string | null;
@@ -5033,6 +5715,8 @@ export interface UpdateLeadRequest {
title?: string;
mobile?: string;
contactName?: string | null;
gender?: PersonGender;
showGenderInName?: boolean;
phone?: string | null;
city?: string | null;
region?: string | null;
+134
View File
@@ -0,0 +1,134 @@
/** مرد، زن، یا هیچکدام (مثلاً فروشگاه) */
export type PersonGender = "male" | "female" | "none";
export const PERSON_GENDERS: PersonGender[] = ["male", "female", "none"];
export const PERSON_GENDER_LABELS: Record<PersonGender, string> = {
male: "مرد",
female: "زن",
none: "هیچکدام",
};
const MALE_TITLE = "جناب آقای";
const FEMALE_TITLE = "سرکار خانم";
function normalizeFa(value: string) {
return value.replace(/ي/g, "ی").replace(/ك/g, "ک").replace(/\s+/g, " ").trim();
}
function splitWords(value: string) {
const parts = normalizeFa(value).split(" ").filter(Boolean);
return {
firstName: parts[0] ?? "",
lastName: parts.slice(1).join(" "),
};
}
/** عنوان ابتدای نام را برمی‌دارد تا فیلدهای نام، عنوان را دوباره ذخیره نکنند. */
export function splitPersonHonorific(firstName: string, lastName: string): {
firstName: string;
lastName: string;
gender: Exclude<PersonGender, "none"> | null;
} {
let full = normalizeFa(`${firstName} ${lastName}`);
let gender: Exclude<PersonGender, "none"> | null = null;
// فاصله اختیاری است: «سرکارخانم» و «جناب آقاي» هر دو عنوان‌اند.
for (;;) {
const male = full.match(/^جناب\s*آقای(?=\s|$)/);
const female = full.match(/^سرکار\s*خانم(?=\s|$)/);
const hit = male?.[0] ?? female?.[0];
if (!hit) break;
gender = male ? "male" : "female";
full = normalizeFa(full.slice(hit.length));
}
const plain = splitWords(full);
return { ...plain, gender };
}
/**
* نام ذخیره‌شده را با عنوان می‌سازد.
* اگر جنسیت یا تیک نفرستاده شود و خود نام عنوان داشته باشد، همان برداشت می‌شود.
*/
export function applyPersonGenderTitle(input: {
firstName: string;
lastName: string;
gender?: PersonGender | null;
showGenderInName?: boolean | null;
}): {
firstName: string;
lastName: string;
gender: PersonGender;
showGenderInName: boolean;
} {
const bare = splitPersonHonorific(input.firstName, input.lastName);
const gender: PersonGender =
input.gender === "male" || input.gender === "female" || input.gender === "none"
? input.gender
: (bare.gender ?? "none");
let show = input.showGenderInName;
if (typeof show !== "boolean") show = bare.gender != null && gender !== "none";
if (gender === "none") show = false;
const body = [bare.firstName, bare.lastName].filter(Boolean).join(" ");
const titled =
show && gender === "male"
? `${MALE_TITLE} ${body}`.trim()
: show && gender === "female"
? `${FEMALE_TITLE} ${body}`.trim()
: body;
const parts = splitWords(titled);
return {
firstName: parts.firstName || "—",
lastName: parts.lastName || "—",
gender,
showGenderInName: show,
};
}
/** پاسخ فیلد جنسیت فرم: مرد، زن، یا هیچکدام. مقدار ناشناس null است. */
export function parsePersonGenderAnswer(raw: string): PersonGender | null {
const text = normalizeFa(raw).toLowerCase();
if (!text) return null;
const has = (words: string[]) => words.some((word) => text === word || text.includes(word));
if (has(["هیچکدام", "none", "نامشخص"])) return "none";
if (has(["female", "woman", "خانم", "زن"])) return "female";
if (has(["male", "man", "آقا", "مرد"])) return "male";
return null;
}
export function isGenderQuestionLabel(label: string): boolean {
return normalizeFa(label).replace(/\s+/g, "").includes("جنسیت");
}
/**
* عنوان روی نام مخاطب لید می‌نشیند.
* اگر نام مخاطب خالی باشد، عنوان لید همان نام شخص است.
*/
export function applyLeadGenderName(input: {
title: string;
contactName?: string | null;
gender?: PersonGender | null;
showGenderInName?: boolean | null;
}): {
title: string;
contactName: string | null;
gender: PersonGender;
showGenderInName: boolean;
} {
const contact = (input.contactName ?? "").trim();
const title = input.title.trim();
const onContact = contact.length > 0;
const named = applyPersonGenderTitle({
firstName: onContact ? contact : title,
lastName: "",
gender: input.gender,
showGenderInName: input.showGenderInName,
});
const full = [named.firstName, named.lastName].filter((part) => part && part !== "—").join(" ");
return {
title: onContact ? title : full || title,
contactName: onContact ? full || null : null,
gender: named.gender,
showGenderInName: named.showGenderInName,
};
}