/** * Журнал правок согласующего: какие поля заявки он поменял на своём шаге и * какими они были до этого. Хранится JSON-массивом в Note-колонке заявки * (у каждой формы своя — см. provisioning.json), показывается подсветкой поля * и строкой «Было: …» (см. shared/ui/FormField). * * Сейчас пишется только при полном праве правки (shared/approval/fullEditRights.ts). */ export interface IFieldChange { /** Ключ поля = internalName колонки = htmlFor (или fieldKey) поля формы. */ field: string; /** Значение ДО первой правки согласующего — в читаемом виде. */ before: string; /** Значение после последней правки — в читаемом виде. */ after: string; byName: string; byEmail: string; /** ISO-время последней правки этого поля. */ at: string; } export type FieldChangeMap = Record; /** Кто правит — для записи в журнал. */ export interface IFieldEditor { name: string; email: string; } /** Своё отображение значения поля: получает значение и весь снимок формы. */ export type FieldFormatter = (value: unknown, data: T) => string; export interface IChangeTrackingConfig { /** Технические ключи без своего поля на форме (Id из справочников и т.п.) — их правки не пишем. */ ignore: ReadonlyArray; /** Поля, у которых значение само по себе нечитаемо (Id компании → её название). */ formatters?: Partial>>; } /** Пустое значение в журнале и в строке «Было: …». */ export const EMPTY_VALUE = '—'; const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/; function formatItem(item: unknown): string { if (typeof item === 'string') return item; if (item && typeof item === 'object') { const record = item as Record; // Компании справочника — { Id, Name }. if (typeof record.Name === 'string') return record.Name; // Языки — { language, level }. if (typeof record.language === 'string') { return record.level ? `${record.language} (${String(record.level)})` : record.language; } } return JSON.stringify(item); } /** Значение поля формы → читаемая строка (дата ДД.ММ.ГГГГ, «Да/Нет», списки через запятую). */ export function formatFieldValue(value: unknown): string { if (value === null || value === undefined) return EMPTY_VALUE; if (typeof value === 'boolean') return value ? 'Да' : 'Нет'; if (typeof value === 'number') return String(value); if (typeof value === 'string') { const trimmed = value.trim(); if (!trimmed) return EMPTY_VALUE; const date = trimmed.match(ISO_DATE); return date ? `${date[3]}.${date[2]}.${date[1]}` : trimmed; } if (Array.isArray(value)) { return value.length > 0 ? value.map(formatItem).join(', ') : EMPTY_VALUE; } return JSON.stringify(value); } function isFieldChange(item: unknown): item is IFieldChange { const record = item as Record | null; return !!record && typeof record.field === 'string' && typeof record.before === 'string'; } /** Разбор JSON журнала из колонки; битое/пустое значение — пустой журнал. */ export function parseFieldChanges(raw: unknown): IFieldChange[] { if (typeof raw !== 'string' || raw.trim() === '') return []; try { const parsed: unknown = JSON.parse(raw); return Array.isArray(parsed) ? parsed.filter(isFieldChange) : []; } catch { return []; } } /** Журнал → JSON для колонки (null — журнал пуст, колонку очищаем). */ // eslint-disable-next-line @rushstack/no-new-null -- null очищает колонку SharePoint, undefined поле бы не тронул export function serializeFieldChanges(changes: IFieldChange[]): string | null { return changes.length > 0 ? JSON.stringify(changes) : null; } export function toChangeMap(changes: IFieldChange[]): FieldChangeMap { const map: FieldChangeMap = {}; changes.forEach((c) => { map[c.field] = c; }); return map; } /** * Дописать в журнал правки одного сохранения. * * saved — то, что сейчас лежит в заявке (до этого сохранения), edited — то, * что сохраняем. В «было» остаётся значение ДО ПЕРВОЙ правки поля — при * повторных сохранениях обновляется только «стало». Если поле вернули к * исходному значению, запись о нём убирается. */ export function mergeFieldChanges( previous: IFieldChange[], saved: T, edited: T, config: IChangeTrackingConfig, editor: IFieldEditor, now: Date ): IFieldChange[] { const display = (key: keyof T, data: T): string => { const formatter = config.formatters?.[key]; return formatter ? formatter(data[key], data) : formatFieldValue(data[key]); }; const byField = toChangeMap(previous); (Object.keys(edited) as (keyof T)[]).forEach((key) => { if (config.ignore.indexOf(key) !== -1) return; if (JSON.stringify(saved[key]) === JSON.stringify(edited[key])) return; const field = String(key); const after = display(key, edited); const before = byField[field] ? byField[field].before : display(key, saved); if (before === after) { delete byField[field]; return; } byField[field] = { field, before, after, byName: editor.name, byEmail: editor.email, at: now.toISOString() }; }); return Object.keys(byField).map((field) => byField[field]); }