/** * Утилиты для сравнения значений полей в правилах. * Используются на сервере (переходы, условная обязательность) и на клиенте (валидация форм). */ export type TableMapEntry = | Map | { rows: Array<{ id: number; values: string[] }>; columns: Array<{ name: string }> }; export interface FieldConditionContext { users?: Array<{ id: number; firstName?: string | null; middleName?: string | null; lastName?: string | null; email?: string; fullName?: string; }>; roles?: Array<{ id: number; name?: string }>; taskTitleMap?: Map; tableMap?: Map; /** * true — мы находимся в модалке первичного создания задачи (псевдо-статус "Заполнение"). * В этом режиме правила mode "creation" / "in_or_creation" считаются выполненными, * а правила mode "in" (в выбранных статусах) НЕ выполняются — чтобы поля можно было * заполнять при создании. Для действия на этапе создания используйте "creation" или "in_or_creation". */ isCreation?: boolean; /** * Список статусов формы с position. Нужен для режимов "до статуса" / "после статуса". */ statuses?: Array<{ id: number; position: number; isFinal?: boolean | null }>; } function safeString(val: unknown): string { if (val === null || val === undefined) return ""; return String(val); } /** * Проверяет, считается ли значение поля "пустым" (не заполненным). */ export function isFieldValueEmpty(value: unknown, fieldType?: string): boolean { if (value === null || value === undefined || value === "") return true; if (Array.isArray(value) && value.length === 0) return true; if (typeof value === "object" && value !== null) { const obj = value as Record; const keys = Object.keys(obj); if (keys.length === 0) return true; const hasNonEmpty = keys.some((k) => { const v = obj[k]; return v !== null && v !== undefined && v !== "" && !(Array.isArray(v) && v.length === 0); }); if (!hasNonEmpty) return true; } return false; } /** * Отображаемый текст одного элемента user-поля (`user:` / `role:` / legacy-число). */ function resolveUserEntryText(entry: string, context: FieldConditionContext): string { if (entry.startsWith("user:")) { const uid = Number(entry.replace("user:", "")); const user = context.users?.find((u) => u.id === uid); if (user) { return ( user.fullName || `${user.firstName || ""} ${user.middleName || ""} ${user.lastName || ""}`.trim() || user.email || entry ); } return entry; } if (entry.startsWith("role:")) { const rid = Number(entry.replace("role:", "")); const role = context.roles?.find((r) => r.id === rid); if (role) { return `Роль: ${role.name || entry}`; } return entry; } const uid = Number(entry); if (!isNaN(uid)) { const user = context.users?.find((u) => u.id === uid); if (user) { return ( user.fullName || `${user.firstName || ""} ${user.middleName || ""} ${user.lastName || ""}`.trim() || user.email || entry ); } } return entry; } /** * Преобразует хранимое значение поля в строку для поиска (отображаемый текст). */ export function resolveFieldDisplayText( value: unknown, field: { type?: string; options?: unknown; autoProlongationLabel?: string | null; }, context: FieldConditionContext = {} ): string { if (value === null || value === undefined || value === "") return ""; const fieldType = field.type || ""; const rawStr = safeString(value); switch (fieldType) { case "user": { // Мультивыбор (allow_multiple): массив ["user:5", "role:2"] — имена через запятую let parsedVal: unknown = value; if (typeof value === "string" && value.startsWith("[")) { try { const parsed = JSON.parse(value); if (Array.isArray(parsed)) parsedVal = parsed; } catch { parsedVal = value; } } if (Array.isArray(parsedVal)) { return parsedVal .map((entry) => resolveUserEntryText(safeString(entry), context)) .filter(Boolean) .join(", "); } return resolveUserEntryText(rawStr, context); } case "task": { const taskId = typeof value === "string" ? parseInt(value, 10) : Number(value); if (!isNaN(taskId)) { const title = context.taskTitleMap?.get(taskId); if (title) return `#${taskId}. ${title}`; } if (typeof value === "object" && value !== null) { const obj = value as Record; const tId = typeof obj.taskId === "number" ? obj.taskId : typeof obj.id === "number" ? obj.id : NaN; if (!isNaN(tId)) { const title = context.taskTitleMap?.get(tId); if (title) return `#${tId}. ${title}`; } } return rawStr; } case "table": { const tableId = (field.options as Record | undefined)?.tableId; const rowId = Number(rawStr); if (tableId && !isNaN(rowId) && context.tableMap?.has(tableId)) { const tableData = context.tableMap.get(tableId)!; if (tableData instanceof Map) { return tableData.get(rowId) || rawStr; } const displayColumn = (field.options as Record | undefined)?.displayColumn; const columns = tableData.columns || []; const colIndex = displayColumn ? columns.findIndex((c) => c.name === displayColumn) : 0; const row = tableData.rows.find((r) => r.id === rowId); if (row) { return row.values[colIndex >= 0 ? colIndex : 0] || `#${row.id}`; } } return rawStr; } case "select": case "radio-group": { const opts = Array.isArray(field.options) ? (field.options as Array<{ label?: string; value?: string; id?: string }>) : []; const opt = opts.find((o) => (o.value ?? o.id) === rawStr); return opt?.label || rawStr; } case "contract-number": { if (typeof value === "object" && value !== null) { const obj = value as Record; return safeString(obj.value || obj.number || ""); } return rawStr; } case "company": { if (typeof value === "object" && value !== null) { const obj = value as Record; return safeString( obj.shortName || obj.name || obj.fullName || obj.value || "" ); } return rawStr; } case "file": { if (Array.isArray(value)) { return value .map((v) => typeof v === "object" && v !== null ? safeString((v as Record).name || v) : safeString(v) ) .join(", "); } if (typeof value === "object" && value !== null) { return safeString( (value as Record).name || (value as Record).url || rawStr ); } return rawStr; } case "geo": { if (typeof value === "object" && value !== null) { const obj = value as Record; return safeString(obj.address || obj.name || obj.value || ""); } return rawStr; } case "toggle": case "checkbox": { if (typeof value === "boolean") { return value ? "Да" : "Нет"; } return rawStr; } case "checklist": { if (Array.isArray(value)) { const checkedCount = value.filter((it: any) => it?.checked).length; const totalCount = Array.isArray(field.options) ? field.options.length : 0; return `Выполнено ${checkedCount} из ${totalCount}`; } return rawStr; } case "date": case "datetime": { if (value === "__auto_prolongation__") { return field.autoProlongationLabel || "Автопролонгация"; } if (typeof value === "string" || value instanceof Date) { const d = value instanceof Date ? value : new Date(value); if (!isNaN(d.getTime())) { return d.toLocaleDateString("ru-RU"); } } return rawStr; } default: return rawStr; } } /** * Системные поля задачи, доступные для использования в правилах. */ export const SYSTEM_TASK_FIELDS = [ { id: -1, code: "_id", name: "ID задачи", type: "number" }, { id: -2, code: "_number", name: "Номер задачи", type: "number" }, { id: -3, code: "_due_date", name: "Срок выполнения", type: "date" }, { id: -4, code: "_status", name: "Статус", type: "text" }, { id: -5, code: "_assignee", name: "Ответственный", type: "user" }, { id: -6, code: "_author", name: "Автор", type: "user" }, { id: -7, code: "title", name: "Название", type: "text" }, { id: -8, code: "description", name: "Описание", type: "textarea" }, { id: -9, code: "createdAt", name: "Дата создания", type: "datetime" }, { id: -10, code: "updatedAt", name: "Дата обновления", type: "datetime" }, { id: -11, code: "completedAt", name: "Дата завершения", type: "datetime" }, { id: -12, code: "isCompleted", name: "Завершена", type: "toggle" }, ] as const; /** * Строит виртуальные значения системных полей задачи для проверки условий. */ export function buildSystemFieldValuesForConditions( task: any, options?: { statusName?: string } ): Array<{ fieldId: number; value: unknown }> { if (!task) return []; return [ { fieldId: -1, value: task.id }, { fieldId: -2, value: task.id }, { fieldId: -3, value: task.dueDate }, { fieldId: -4, value: options?.statusName ?? task.currentStatusId }, { fieldId: -5, value: task.assignedTo ? `user:${task.assignedTo}` : null }, { fieldId: -6, value: task.createdBy ? `user:${task.createdBy}` : null }, { fieldId: -7, value: task.title }, { fieldId: -8, value: task.description }, { fieldId: -9, value: task.createdAt }, { fieldId: -10, value: task.updatedAt }, { fieldId: -11, value: task.completedAt }, { fieldId: -12, value: task.isCompleted }, ]; } /** * Проверяет, соответствуют ли значения полей заданным правилам. */ export function fieldConditionsMatch( rules: { fieldCode?: string; value?: string; operator?: "contains" | "empty" | "filled"; tableMode?: "value" | "column_user" }[], fieldValues: Array<{ fieldId: number; value: unknown }>, fields: Array<{ id: number; code: string; type?: string; options?: unknown; autoProlongationLabel?: string | null; }>, context: FieldConditionContext = {} ): boolean { if (!rules || rules.length === 0) return true; for (const rule of rules) { if (!rule.fieldCode) return false; const field = fields.find((f) => f.code === rule.fieldCode); if (!field) return false; // Режим "пользователь из колонки" — это конфигурация выборки согласующего, // а не условие на значение поля. Пропускаем, чтобы не ломать проверку. if (rule.tableMode === "column_user") continue; const fv = fieldValues.find((fv) => fv.fieldId === field.id); const operator = rule.operator || "contains"; if (operator === "empty") { if (!isFieldValueEmpty(fv?.value, field.type)) return false; continue; } if (operator === "filled") { if (isFieldValueEmpty(fv?.value, field.type)) return false; continue; } if (!rule.value) return false; const displayText = resolveFieldDisplayText(fv?.value, field, context); const ruleVal = rule.value.toLowerCase(); if (!displayText.toLowerCase().includes(ruleVal)) return false; } return true; } /** * Режимы условия по статусу для правила поля. */ export type FieldRuleStatusMode = "any" | "creation" | "in" | "in_or_creation"; /** * Условие по статусу: в каких статусах применяется правило. */ export interface FieldRuleStatusCondition { mode: FieldRuleStatusMode; /** Набор статусов для режимов in / in_or_creation. */ statusIds?: number[]; } /** * Универсальная модель условного правила поля. * Используется для обязательности, неизменности и видимости. */ export interface FieldRule { enabled?: boolean; /** Условие по статусу (новое). */ statusCondition?: FieldRuleStatusCondition | null; /** Legacy: старая привязка к одному статусу. Оставлено для обратной совместимости. */ statusId?: number | "__creation__" | null; logic?: "and" | "or"; conditions?: Array<{ fieldCode: string; operator?: "contains" | "empty" | "filled"; value?: string; tableMode?: "value" | "column_user"; }>; } /** Обратная совместимость со старым названием. */ export type FieldRequiredRules = FieldRule; /** * Нормализует правило: превращает legacy statusId в statusCondition. */ export function normalizeFieldRule(rules: FieldRule | null | undefined): FieldRule | null { if (!rules) return null; if (rules.statusCondition != null) { return rules; } const { statusId, ...rest } = rules; let statusCondition: FieldRuleStatusCondition; if (statusId === "__creation__") { statusCondition = { mode: "creation" }; } else if (statusId == null) { statusCondition = { mode: "any" }; } else { statusCondition = { mode: "in", statusIds: [statusId] }; } return { ...rest, statusCondition }; } function ruleStatusConditionMatches( statusCondition: FieldRuleStatusCondition | null | undefined, task: { currentStatusId?: number | null } | null, context: FieldConditionContext ): boolean { const sc = statusCondition ?? { mode: "any" as const }; const currentStatusId = task?.currentStatusId; switch (sc.mode) { case "any": return true; case "creation": return !!context.isCreation; case "in": // Режим "in" — только в выбранных реальных статусах, НЕ при первичном создании. // Чтобы правило работало и при создании, используйте "in_or_creation". return !context.isCreation && currentStatusId != null && (sc.statusIds ?? []).includes(currentStatusId); case "in_or_creation": return ( !!context.isCreation || (currentStatusId != null && (sc.statusIds ?? []).includes(currentStatusId)) ); default: // Неизвестный режим считаем неактивным (безопасный дефолт). return false; } } /** * Универсально проверяет, выполняется ли правило для поля. */ export function evaluateFieldRule( rules: FieldRule | null | undefined, task: { currentStatusId?: number | null } | null, fieldValues: Array<{ fieldId: number; value: unknown }>, fields: Array<{ id: number; code: string; type?: string; options?: unknown; autoProlongationLabel?: string | null }>, context: FieldConditionContext = {} ): boolean { const normalized = normalizeFieldRule(rules); if (!normalized || !normalized.enabled) { return false; } if (!ruleStatusConditionMatches(normalized.statusCondition, task, context)) { return false; } const conditions = normalized.conditions || []; if (conditions.length === 0) { return true; } const logic = normalized.logic || "and"; if (logic === "or") { return conditions.some((rule) => fieldConditionsMatch([rule], fieldValues, fields, context) ); } return fieldConditionsMatch(conditions, fieldValues, fields, context); } /** * Проверяет, является ли поле обязательным с учётом условных правил. */ export function isFieldRequired( field: { id: number; code: string; type?: string; isRequired?: boolean | null; requiredRules?: FieldRule | null; }, task: { currentStatusId?: number | null } | null, fieldValues: Array<{ fieldId: number; value: unknown }>, fields: Array<{ id: number; code: string; type?: string; options?: unknown; autoProlongationLabel?: string | null }>, context: FieldConditionContext = {} ): boolean { // Если включено условное правило, оно полностью определяет обязательность поля. // Старый boolean-флаг isRequired используется только когда условных правил нет или они выключены. // Это предотвращает ситуацию, когда поле остаётся обязательным при создании, // хотя правило настроено только для конкретного статуса (mode "in"). if (field.requiredRules && field.requiredRules.enabled) { return evaluateFieldRule(field.requiredRules, task, fieldValues, fields, context); } return !!field.isRequired; } /** * Проверяет, является ли поле неизменным (read-only) с учётом условных правил. */ export function isFieldImmutable( field: { id: number; code: string; type?: string; immutableRules?: FieldRule | null; }, task: { currentStatusId?: number | null } | null, fieldValues: Array<{ fieldId: number; value: unknown }>, fields: Array<{ id: number; code: string; type?: string; options?: unknown; autoProlongationLabel?: string | null }>, context: FieldConditionContext = {} ): boolean { return evaluateFieldRule(field.immutableRules, task, fieldValues, fields, context); } /** * Проверяет, должно ли поле быть видимым с учётом условных правил. * Если правила не включены — поле видимо по умолчанию. */ export function isFieldVisible( field: { id: number; code: string; type?: string; visibilityRules?: FieldRule | null; }, task: { currentStatusId?: number | null } | null, fieldValues: Array<{ fieldId: number; value: unknown }>, fields: Array<{ id: number; code: string; type?: string; options?: unknown; autoProlongationLabel?: string | null }>, context: FieldConditionContext = {} ): boolean { if (!field.visibilityRules || !field.visibilityRules.enabled) { return true; } return evaluateFieldRule(field.visibilityRules, task, fieldValues, fields, context); }