feat: подсветка полей, изменённых согласующим, с прежними значениями
Tests / frontend-test (push) Successful in 1m15s

This commit is contained in:
Тимур Городецкий
2026-09-24 17:36:39 +05:00
parent 8cfb364f9c
commit 286b449099
27 changed files with 590 additions and 54 deletions
@@ -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';