feat: подсветка полей, изменённых согласующим, с прежними значениями
Tests / frontend-test (push) Successful in 1m15s
Tests / frontend-test (push) Successful in 1m15s
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { formatFieldValue, mergeFieldChanges, parseFieldChanges, IChangeTrackingConfig } from './fieldChanges';
|
||||
|
||||
interface IData {
|
||||
Position: string;
|
||||
PositionId: string;
|
||||
Salary: string;
|
||||
StartDate: string;
|
||||
Remote: boolean;
|
||||
Companies: { Id: string; Name: string }[];
|
||||
}
|
||||
|
||||
const base: IData = {
|
||||
Position: 'Инженер',
|
||||
PositionId: '1',
|
||||
Salary: '300000',
|
||||
StartDate: '2026-10-01',
|
||||
Remote: false,
|
||||
Companies: [{ Id: 'a', Name: 'ТОО А' }]
|
||||
};
|
||||
|
||||
const config: IChangeTrackingConfig<IData> = { ignore: ['PositionId'] };
|
||||
const editor = { name: 'Алтынай Бултурукова', email: 'a.bulturukova@tnsu.kz' };
|
||||
const now = new Date('2026-09-24T10:00:00Z');
|
||||
|
||||
describe('formatFieldValue', () => {
|
||||
it('форматирует дату, булево, пустое и списки', () => {
|
||||
expect(formatFieldValue('2026-10-01')).toBe('01.10.2026');
|
||||
expect(formatFieldValue(true)).toBe('Да');
|
||||
expect(formatFieldValue('')).toBe('—');
|
||||
expect(formatFieldValue([])).toBe('—');
|
||||
expect(formatFieldValue([{ Id: 'a', Name: 'ТОО А' }, { Id: 'b', Name: 'ТОО Б' }])).toBe('ТОО А, ТОО Б');
|
||||
expect(formatFieldValue([{ language: 'Английский', level: 'Средний' }])).toBe('Английский (Средний)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeFieldChanges', () => {
|
||||
it('пишет изменённые поля и пропускает технические', () => {
|
||||
const edited = { ...base, Position: 'Ведущий инженер', PositionId: '2' };
|
||||
const result = mergeFieldChanges([], base, edited, config, editor, now);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
field: 'Position',
|
||||
before: 'Инженер',
|
||||
after: 'Ведущий инженер',
|
||||
byName: editor.name,
|
||||
byEmail: editor.email,
|
||||
at: now.toISOString()
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('при повторной правке сохраняет исходное «было»', () => {
|
||||
const first = mergeFieldChanges([], base, { ...base, Salary: '350000' }, config, editor, now);
|
||||
const saved = { ...base, Salary: '350000' };
|
||||
const second = mergeFieldChanges(first, saved, { ...saved, Salary: '400000' }, config, editor, now);
|
||||
expect(second).toHaveLength(1);
|
||||
expect(second[0].before).toBe('300000');
|
||||
expect(second[0].after).toBe('400000');
|
||||
});
|
||||
|
||||
it('убирает запись, если поле вернули к исходному значению', () => {
|
||||
const first = mergeFieldChanges([], base, { ...base, Remote: true }, config, editor, now);
|
||||
const saved = { ...base, Remote: true };
|
||||
expect(mergeFieldChanges(first, saved, base, config, editor, now)).toEqual([]);
|
||||
});
|
||||
|
||||
it('не трогает прежние записи по полям, которые в этот раз не менялись', () => {
|
||||
const first = mergeFieldChanges([], base, { ...base, StartDate: '2026-11-01' }, config, editor, now);
|
||||
const saved = { ...base, StartDate: '2026-11-01' };
|
||||
const second = mergeFieldChanges(first, saved, { ...saved, Companies: [] }, config, editor, now);
|
||||
expect(second.map((c) => c.field)).toEqual(['StartDate', 'Companies']);
|
||||
expect(second[1]).toMatchObject({ before: 'ТОО А', after: '—' });
|
||||
});
|
||||
|
||||
it('использует свой форматтер поля', () => {
|
||||
const withFormatter: IChangeTrackingConfig<IData> = {
|
||||
ignore: [],
|
||||
formatters: { PositionId: (v) => `#${String(v)}` }
|
||||
};
|
||||
const result = mergeFieldChanges([], base, { ...base, PositionId: '7' }, withFormatter, editor, now);
|
||||
expect(result[0]).toMatchObject({ before: '#1', after: '#7' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFieldChanges', () => {
|
||||
it('битый или пустой JSON — пустой журнал', () => {
|
||||
expect(parseFieldChanges('')).toEqual([]);
|
||||
expect(parseFieldChanges('{не json')).toEqual([]);
|
||||
expect(parseFieldChanges(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Журнал правок согласующего: какие поля заявки он поменял на своём шаге и
|
||||
* какими они были до этого. Хранится 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<string, IFieldChange>;
|
||||
|
||||
/** Кто правит — для записи в журнал. */
|
||||
export interface IFieldEditor {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
/** Своё отображение значения поля: получает значение и весь снимок формы. */
|
||||
export type FieldFormatter<T> = (value: unknown, data: T) => string;
|
||||
|
||||
export interface IChangeTrackingConfig<T> {
|
||||
/** Технические ключи без своего поля на форме (Id из справочников и т.п.) — их правки не пишем. */
|
||||
ignore: ReadonlyArray<keyof T>;
|
||||
/** Поля, у которых значение само по себе нечитаемо (Id компании → её название). */
|
||||
formatters?: Partial<Record<keyof T, FieldFormatter<T>>>;
|
||||
}
|
||||
|
||||
/** Пустое значение в журнале и в строке «Было: …». */
|
||||
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<string, unknown>;
|
||||
// Компании справочника — { 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<string, unknown> | 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 — журнал пуст, колонку очищаем). */
|
||||
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<T extends object>(
|
||||
previous: IFieldChange[],
|
||||
saved: T,
|
||||
edited: T,
|
||||
config: IChangeTrackingConfig<T>,
|
||||
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]);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as React from 'react';
|
||||
import { Alert } from '@tnsu/ui-kit';
|
||||
import { IFieldChange } from '../../fieldChanges/fieldChanges';
|
||||
import { formatDateTime } from './formatDateTime';
|
||||
|
||||
interface IFieldChangesBannerProps {
|
||||
changes: IFieldChange[];
|
||||
}
|
||||
|
||||
/** «поле / поля / полей» по числу. */
|
||||
function fieldsWord(count: number): string {
|
||||
const mod10 = count % 10;
|
||||
const mod100 = count % 100;
|
||||
if (mod10 === 1 && mod100 !== 11) return 'поле';
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'поля';
|
||||
return 'полей';
|
||||
}
|
||||
|
||||
/**
|
||||
* Сводка над формой: сколько полей поменял согласующий и когда. Сами поля
|
||||
* подсвечены по месту (см. shared/ui/FormField). На печати не выводится.
|
||||
*/
|
||||
export const FieldChangesBanner: React.FC<IFieldChangesBannerProps> = ({ changes }) => {
|
||||
if (changes.length === 0) return null;
|
||||
|
||||
const names = changes
|
||||
.map((c) => c.byName)
|
||||
.filter((name, i, all) => !!name && all.indexOf(name) === i)
|
||||
.join(', ');
|
||||
const lastAt = changes.reduce((latest, c) => (c.at > latest ? c.at : latest), changes[0].at);
|
||||
|
||||
return (
|
||||
<div className="rp-no-print">
|
||||
<Alert variant="info">
|
||||
Согласующий{names ? ` (${names})` : ''} изменил {changes.length} {fieldsWord(changes.length)}, последнее
|
||||
изменение — {formatDateTime(lastAt)}. Изменённые поля подсвечены, под каждым указано прежнее значение.
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import { FieldChangeMap, IFieldChange } from '../../fieldChanges/fieldChanges';
|
||||
|
||||
/**
|
||||
* Журнал правок согласующего для всей формы сразу: каждое поле само находит
|
||||
* свою правку по ключу (см. shared/ui/FormField), поэтому секции форм про
|
||||
* журнал ничего не знают.
|
||||
*/
|
||||
const FieldChangesContext = React.createContext<FieldChangeMap>({});
|
||||
|
||||
interface IFieldChangesProviderProps {
|
||||
changes: FieldChangeMap;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FieldChangesProvider: React.FC<IFieldChangesProviderProps> = ({ changes, children }) => (
|
||||
<FieldChangesContext.Provider value={changes}>{children}</FieldChangesContext.Provider>
|
||||
);
|
||||
|
||||
/** Правка поля по ключу; undefined — поле не менялось (или журнала нет). */
|
||||
export function useFieldChange(fieldKey: string | undefined): IFieldChange | undefined {
|
||||
const changes = React.useContext(FieldChangesContext);
|
||||
return fieldKey ? changes[fieldKey] : undefined;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
function pad(n: number): string {
|
||||
return n < 10 ? `0${n}` : String(n);
|
||||
}
|
||||
|
||||
/** ISO-время → «ДД.ММ.ГГГГ ЧЧ:ММ» по локальному времени; битое значение — как есть. */
|
||||
export function formatDateTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
// Поле, изменённое согласующим (см. shared/fieldChanges): золотая полоса слева
|
||||
// и лёгкая подложка — заметно, но не спорит с ошибками (красный) и фокусом.
|
||||
.changed {
|
||||
padding: $sp-1 0 $sp-1 $sp-2;
|
||||
border-left: 3px solid $gold-500;
|
||||
border-radius: $r-sm;
|
||||
background: $gold-50;
|
||||
}
|
||||
|
||||
.before {
|
||||
margin-top: $sp-1;
|
||||
font-size: $fs-caption;
|
||||
color: $gray-700;
|
||||
overflow-wrap: anywhere; // длинные значения (списки компаний) не распирают узкую колонку
|
||||
}
|
||||
|
||||
.beforeLabel {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// На печати подсветка не нужна — бумажная форма показывает итоговые значения.
|
||||
@media print {
|
||||
.changed {
|
||||
padding: 0;
|
||||
border-left: 0;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as React from 'react';
|
||||
import { FormField as KitFormField } from '@tnsu/ui-kit';
|
||||
import styles from './FormField.module.scss';
|
||||
import { useFieldChange } from '../FieldChanges/FieldChangesContext';
|
||||
import { formatDateTime } from '../FieldChanges/formatDateTime';
|
||||
|
||||
interface IFormFieldProps {
|
||||
label: string;
|
||||
htmlFor?: string;
|
||||
required?: boolean;
|
||||
/** Занять обе колонки сетки. */
|
||||
full?: boolean;
|
||||
/**
|
||||
* Ключ поля в журнале правок (internalName колонки). По умолчанию — htmlFor;
|
||||
* задаётся явно у полей без htmlFor (люди, мультивыбор, переключатели).
|
||||
*/
|
||||
fieldKey?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* FormField из ui-kit + подсветка правки согласующего: если поле есть в
|
||||
* журнале (см. FieldChangesProvider), контрол подсвечивается, а под ним —
|
||||
* прежнее значение. Без журнала ведёт себя ровно как FormField из ui-kit.
|
||||
*/
|
||||
export const FormField: React.FC<IFormFieldProps> = ({ fieldKey, children, ...fieldProps }) => {
|
||||
const change = useFieldChange(fieldKey ?? fieldProps.htmlFor);
|
||||
if (!change) {
|
||||
return <KitFormField {...fieldProps}>{children}</KitFormField>;
|
||||
}
|
||||
|
||||
return (
|
||||
<KitFormField {...fieldProps}>
|
||||
<div className={styles.changed}>{children}</div>
|
||||
<div className={`${styles.before} rp-no-print`} title={`Изменено: ${change.byName}, ${formatDateTime(change.at)}`}>
|
||||
<span className={styles.beforeLabel}>Было:</span> {change.before}
|
||||
</div>
|
||||
</KitFormField>
|
||||
);
|
||||
};
|
||||
@@ -9,7 +9,6 @@ export {
|
||||
Alert,
|
||||
FormCard,
|
||||
FormGrid,
|
||||
FormField,
|
||||
TextInput,
|
||||
PhoneInput,
|
||||
MoneyInput,
|
||||
@@ -32,6 +31,11 @@ export {
|
||||
export type { ISelectOption, IConfirmOptions } from '@tnsu/ui-kit';
|
||||
|
||||
// --- Пока локальные (не перенесены: SP/бизнес/отложенные) ---
|
||||
// FormField — обёртка над ui-kit с подсветкой правок согласующего (журнал в
|
||||
// FieldChangesProvider); без журнала ведёт себя как FormField из ui-kit.
|
||||
export { FormField } from './FormField/FormField';
|
||||
export { FieldChangesProvider } from './FieldChanges/FieldChangesContext';
|
||||
export { FieldChangesBanner } from './FieldChanges/FieldChangesBanner';
|
||||
export { ItemsTable } from './ItemsTable/ItemsTable';
|
||||
export type { IItemsTableProps } from './ItemsTable/ItemsTable';
|
||||
export type { ILineItem } from './ItemsTable/ILineItem';
|
||||
|
||||
Reference in New Issue
Block a user