3 Commits

Author SHA1 Message Date
Тимур Городецкий 7488681f68 feat: фильтры таблиц заявок запоминаются, видно какие включены
Tests / frontend-test (push) Failing after 2m11s
Пользователи жаловались, что фильтры постоянно сбрасываются. Тип, статус,
период и сортировка теперь хранятся в localStorage — отдельно на каждого
пользователя и таблицу, с проверкой значений при чтении. Чтобы забытый
фильтр не выглядел как «пропали заявки», над таблицей строка «Включены
фильтры» с чипами, «Сбросить все» (чистит и хранилище) и «Показано X из Y».

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 15:25:02 +05:00
Тимур Городецкий f64aa909cd fix: примечание для согласующих — после вложений, перед маршрутом
Инициатор пишет примечание, уже заполнив заявку, и сразу видит под ним,
кому оно уйдёт. В просмотре согласующего порядок тот же; плашка вверху
карточки не меняется.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 15:07:35 +05:00
Тимур Городецкий 545db1aee6 feat: примечание для согласующих в заявке на приём
Инициатору нужно передать согласующим важную информацию так, чтобы её не
пропустили. Новый раздел «6. Примечание для согласующих» (текст + «Важное»);
у согласующих и исполнителей заполненное примечание показывается плашкой
над разделами заявки, важное выделено цветом и иконкой. Сделано общим
модулем shared/approverNote, чтобы подключать в другие формы. Колонки
HireApproverNote (Note) и HireApproverNoteImportant (Boolean).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 15:02:11 +05:00
22 changed files with 742 additions and 8 deletions
@@ -0,0 +1,75 @@
@import '../../../../styles/design-tokens';
.bar {
display: flex;
flex-direction: column;
gap: $sp-1;
margin-bottom: $sp-4;
padding: $sp-2 $sp-3;
border: 1px solid $gold-300;
border-radius: $r-sm;
background: $gold-50;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
}
.main {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $sp-2;
}
.label {
font-weight: 600;
white-space: nowrap;
}
.chips {
display: flex;
flex: 1;
flex-wrap: wrap;
gap: $sp-1;
min-width: 0;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 4px 6px 4px 10px;
border: 1px solid $gray-200;
border-radius: $r-full;
background: $white;
word-break: break-word;
}
.chipRemove {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 18px;
height: 18px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: $gray-500;
font-size: 15px;
line-height: 1;
cursor: pointer;
transition: .15s;
&:hover {
background: $gray-200;
color: $gray-900;
}
}
.count {
font-size: $fs-caption;
color: $gray-500;
}
@@ -0,0 +1,59 @@
import * as React from 'react';
import { Button } from '@tnsu/ui-kit';
import { IRequestType } from '../../shared/approval';
import { ITableFilters } from './requestsTableFilters';
import { activeFilterChips } from './activeFilterChips';
import styles from './ActiveFiltersBar.module.scss';
interface Props {
filters: ITableFilters;
types: IRequestType[] | undefined;
onChange: (filters: ITableFilters) => void;
onReset: () => void;
/** Сколько заявок показано после фильтров; undefined — ещё грузится (счётчик не показываем). */
shown: number | undefined;
/** Сколько всего в разделе без фильтров; undefined — неизвестно (тогда «из …» не пишем). */
total: number | undefined;
}
/**
* «Включены фильтры: …» над таблицей. Фильтры запоминаются между заходами (см.
* filtersStorage.ts), и без этой строки человек мог бы забыть про включённый
* фильтр и решить, что заявки пропали. Видна только при включённом фильтре.
*/
export const ActiveFiltersBar: React.FC<Props> = ({ filters, types, onChange, onReset, shown, total }) => {
const chips = activeFilterChips(filters, types);
if (chips.length === 0) return null;
return (
<div className={styles.bar} role="status">
<div className={styles.main}>
<span className={styles.label}>Включены фильтры:</span>
<div className={styles.chips}>
{chips.map((chip) => (
<span key={chip.key} className={styles.chip}>
{chip.label}
<button
type="button"
className={styles.chipRemove}
aria-label={`Убрать фильтр «${chip.label}»`}
title="Убрать фильтр"
onClick={() => onChange(chip.without)}
>
×
</button>
</span>
))}
</div>
<Button variant="ghost" size="sm" onClick={onReset}>
Сбросить все
</Button>
</div>
{shown !== undefined && (
<div className={styles.count}>
{total !== undefined && total !== shown ? `Показано ${shown} из ${total}` : `Показано ${shown}`}
</div>
)}
</div>
);
};
@@ -31,6 +31,8 @@ import { useRoutedQuery } from '../../shared/usePersistedQuery';
import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache'; import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
import { Pager } from './Pager'; import { Pager } from './Pager';
import { RequestsFilterBar } from './RequestsFilterBar'; import { RequestsFilterBar } from './RequestsFilterBar';
import { ActiveFiltersBar } from './ActiveFiltersBar';
import { effectiveUserEmail } from '../../shared/impersonation';
import { RequestHistoryDialog } from './RequestHistoryDialog'; import { RequestHistoryDialog } from './RequestHistoryDialog';
import { SortableHeader } from './SortableHeader'; import { SortableHeader } from './SortableHeader';
import { filterRows, hasActiveFilters, ISortState, SortKey, sortRows } from './requestsTableFilters'; import { filterRows, hasActiveFilters, ISortState, SortKey, sortRows } from './requestsTableFilters';
@@ -441,6 +443,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
// нет — всё как раньше. // нет — всё как раньше.
const refinement = useTableRefinement({ const refinement = useTableRefinement({
tableKey: `${view}|${allRequestsType?.id ?? ''}`, tableKey: `${view}|${allRequestsType?.id ?? ''}`,
storageScope: effectiveUserEmail(context),
statusFromRoute: view === 'own:created', statusFromRoute: view === 'own:created',
routeStatus: statusFilter, routeStatus: statusFilter,
onRouteStatusChange: onStatusFilterChange, onRouteStatusChange: onStatusFilterChange,
@@ -454,6 +457,10 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
// Листание страниц и смена фильтра его переиспользуют (заново по сети не // Листание страниц и смена фильтра его переиспользуют (заново по сети не
// идём), а автообновление и rp-requests-changed перечитывают. // идём), а автообновление и rp-requests-changed перечитывают.
const fullListRef = React.useRef<{ key: string; rows: IMyRequest[] } | undefined>(undefined); const fullListRef = React.useRef<{ key: string; rows: IMyRequest[] } | undefined>(undefined);
// Сколько заявок в разделе БЕЗ фильтров — для «Показано 3 из 10» в строке
// включённых фильтров. undefined — неизвестно (постраничная загрузка или
// статус из маршрута уже сузил список на сервере): тогда пишем просто «Показано 3».
const [unfilteredTotal, setUnfilteredTotal] = React.useState<number | undefined>(undefined);
// Какой ИМЕННО таблице принадлежат лежащие в состоянии rows/totalRows. // Какой ИМЕННО таблице принадлежат лежащие в состоянии rows/totalRows.
// Компонент один на все разделы (см. LIST_VIEWS в RequestPortal), поэтому при // Компонент один на все разделы (см. LIST_VIEWS в RequestPortal), поэтому при
@@ -654,6 +661,11 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
setRows(fresh.rows); setRows(fresh.rows);
setTotalRows(fresh.totalRows); setTotalRows(fresh.totalRows);
} }
const full = fullListRef.current;
const narrowedByRouteStatus = view === 'own:created' && !!statusFilter;
setUnfilteredTotal(
isRefined && !narrowedByRouteStatus && full && full.key === fullKey ? full.rows.length : undefined
);
// Данные точно от текущей таблицы — теперь по ним можно судить о // Данные точно от текущей таблицы — теперь по ним можно судить о
// количестве страниц (см. сброс страницы ниже). // количестве страниц (см. сброс страницы ниже).
setLoadedKey(dataKey); setLoadedKey(dataKey);
@@ -720,6 +732,18 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
if (rows && page > totalPages) onPageChange(totalPages); if (rows && page > totalPages) onPageChange(totalPages);
}, [loadedKey, dataKey, rows, page, totalPages, onPageChange]); }, [loadedKey, dataKey, rows, page, totalPages, onPageChange]);
// Запомненный тип заявки, которого больше нет среди доступных пользователю
// (тип убрали или доступ к нему отозвали), — снимаем: иначе таблица навсегда
// осталась бы пустой по фильтру, который в выпадающем списке даже не выбрать.
const storedTypeId = refinement.barFilters.typeId;
React.useEffect(() => {
if (view === 'all' || !types || storedTypeId === undefined) return;
if (types.some((t) => t.id === storedTypeId)) return;
refinement.onFiltersChange({ ...refinement.barFilters, typeId: undefined });
// refinement пересобирается на каждый рендер — реагируем только на сам тип и справочник.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view, types, storedTypeId]);
return ( return (
<section className={styles.view}> <section className={styles.view}>
<div className={styles.head}> <div className={styles.head}>
@@ -743,10 +767,21 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
types={view === 'all' ? undefined : types} types={view === 'all' ? undefined : types}
// Статус — там же, где его колонка: в остальных разделах он у всех строк один. // Статус — там же, где его колонка: в остальных разделах он у всех строк один.
showStatus={columnsFor(view, '').status} showStatus={columnsFor(view, '').status}
canReset={refinement.canReset} // При включённых фильтрах сброс — в строке ActiveFiltersBar («Сбросить все»);
// здесь кнопка остаётся только для случая «изменена одна сортировка».
canReset={refinement.canReset && !hasActiveFilters(refinement.barFilters)}
onReset={refinement.onReset} onReset={refinement.onReset}
/> />
<ActiveFiltersBar
filters={refinement.barFilters}
types={types}
onChange={refinement.onFiltersChange}
onReset={refinement.onReset}
shown={loaded ? total : undefined}
total={unfilteredTotal}
/>
{error && <div className={styles.error}>Не удалось загрузить заявки: {error}</div>} {error && <div className={styles.error}>Не удалось загрузить заявки: {error}</div>}
{deleteError && <div className={styles.error}>{deleteError}</div>} {deleteError && <div className={styles.error}>{deleteError}</div>}
{!error && !loaded && <div className={styles.empty}>Загрузка…</div>} {!error && !loaded && <div className={styles.empty}>Загрузка…</div>}
@@ -0,0 +1,37 @@
import { activeFilterChips, formatDay } from './activeFilterChips';
import { EMPTY_FILTERS } from './requestsTableFilters';
import { IRequestType } from '../../shared/approval';
const TYPES = [{ id: 1, name: 'Заявка на отпуск' }] as IRequestType[];
describe('activeFilterChips', () => {
it('без фильтров — чипов нет', () => {
expect(activeFilterChips(EMPTY_FILTERS, TYPES)).toEqual([]);
});
it('чип на каждый фильтр, убирание чипа снимает только его', () => {
const filters = { typeId: 1, status: 'Черновик', dateFrom: '2026-09-01', dateTo: '2026-09-30' };
const chips = activeFilterChips(filters, TYPES);
expect(chips.map((c) => c.label)).toEqual([
'Тип: Заявка на отпуск',
'Статус: Черновик',
'Дата: 01.09.2026 – 30.09.2026',
]);
expect(chips[2].without).toEqual({ typeId: 1, status: 'Черновик', dateFrom: '', dateTo: '' });
});
it('открытый с одной стороны период', () => {
expect(activeFilterChips({ ...EMPTY_FILTERS, dateFrom: '2026-09-01' }, TYPES)[0].label).toBe('Дата: с 01.09.2026');
expect(activeFilterChips({ ...EMPTY_FILTERS, dateTo: '2026-09-30' }, TYPES)[0].label).toBe('Дата: по 30.09.2026');
});
it('тип ещё не загружен — чип по номеру, а не пропадает', () => {
expect(activeFilterChips({ ...EMPTY_FILTERS, typeId: 7 }, undefined)[0].label).toBe('Тип: №7');
});
});
describe('formatDay', () => {
it('ISO-день в ДД.ММ.ГГГГ', () => {
expect(formatDay('2026-10-08')).toBe('08.10.2026');
});
});
@@ -0,0 +1,49 @@
import { IRequestType } from '../../shared/approval';
import { EMPTY_FILTERS, ITableFilters } from './requestsTableFilters';
/** Чип включённого фильтра: подпись + каким станет набор фильтров, если чип убрать. */
export interface IFilterChip {
key: 'type' | 'status' | 'period';
label: string;
without: ITableFilters;
}
/** 'YYYY-MM-DD' → 'ДД.ММ.ГГГГ'. */
export function formatDay(day: string): string {
const [y, m, d] = day.split('-');
return y && m && d ? `${d}.${m}.${y}` : day;
}
function periodLabel(from: string, to: string): string {
if (from && to) return `${formatDay(from)} – ${formatDay(to)}`;
return from ? `с ${formatDay(from)}` : `по ${formatDay(to)}`;
}
/**
* Чипы для строки «Включены фильтры». Тип показывается по названию; если тип
* не найден среди доступных (types ещё грузятся) — по номеру, чтобы чип не
* исчезал и фильтр нельзя было «потерять» из виду.
*/
export function activeFilterChips(filters: ITableFilters, types: IRequestType[] | undefined): IFilterChip[] {
const chips: IFilterChip[] = [];
if (filters.typeId !== undefined) {
const type = types ? types.filter((t) => t.id === filters.typeId)[0] : undefined;
chips.push({
key: 'type',
label: `Тип: ${type ? type.name : `№${filters.typeId}`}`,
without: { ...filters, typeId: EMPTY_FILTERS.typeId },
});
}
if (filters.status) {
chips.push({ key: 'status', label: `Статус: ${filters.status}`, without: { ...filters, status: '' } });
}
if (filters.dateFrom || filters.dateTo) {
chips.push({
key: 'period',
label: `Дата: ${periodLabel(filters.dateFrom, filters.dateTo)}`,
without: { ...filters, dateFrom: '', dateTo: '' },
});
}
return chips;
}
@@ -0,0 +1,63 @@
import { parseStoredRefinement, readStoredRefinement, writeStoredRefinement } from './filtersStorage';
import { DEFAULT_SORT, EMPTY_FILTERS } from './requestsTableFilters';
const SCOPE = 'User@tnsu.kz';
const TABLE = 'own:created|';
describe('parseStoredRefinement', () => {
it('корректная запись восстанавливается целиком', () => {
const raw = JSON.stringify({
filters: { typeId: 5, status: 'На согласовании', dateFrom: '2026-09-01', dateTo: '2026-09-30' },
sort: { key: 'status', direction: 'asc' },
});
expect(parseStoredRefinement(raw)).toEqual({
filters: { typeId: 5, status: 'На согласовании', dateFrom: '2026-09-01', dateTo: '2026-09-30' },
sort: { key: 'status', direction: 'asc' },
});
});
it('пусто или битый JSON — ничего не восстанавливаем', () => {
expect(parseStoredRefinement(null)).toBeUndefined();
expect(parseStoredRefinement('{oops')).toBeUndefined();
});
it('неизвестный статус, кривые даты и сортировка отбрасываются по отдельности', () => {
const raw = JSON.stringify({
filters: { typeId: '5', status: 'Неизвестный', dateFrom: '01.09.2026', dateTo: '2026-09-30' },
sort: { key: 'author', direction: 'up' },
});
expect(parseStoredRefinement(raw)).toEqual({
filters: { ...EMPTY_FILTERS, dateTo: '2026-09-30' },
sort: DEFAULT_SORT,
});
});
it('перевёрнутый период не восстанавливается', () => {
const raw = JSON.stringify({ filters: { dateFrom: '2026-10-01', dateTo: '2026-09-01' } });
expect(parseStoredRefinement(raw)?.filters).toEqual(EMPTY_FILTERS);
});
});
describe('read/writeStoredRefinement', () => {
beforeEach(() => window.localStorage.clear());
it('запись и чтение по пользователю и таблице; регистр email не важен', () => {
const value = { filters: { ...EMPTY_FILTERS, status: 'Черновик' }, sort: DEFAULT_SORT };
writeStoredRefinement(SCOPE, TABLE, value);
expect(readStoredRefinement('user@tnsu.kz', TABLE)).toEqual(value);
expect(readStoredRefinement('other@tnsu.kz', TABLE)).toBeUndefined();
expect(readStoredRefinement(SCOPE, 'approvals|')).toBeUndefined();
});
it('состояние по умолчанию удаляет запись — сброс чистит хранилище', () => {
writeStoredRefinement(SCOPE, TABLE, { filters: { ...EMPTY_FILTERS, status: 'Черновик' }, sort: DEFAULT_SORT });
writeStoredRefinement(SCOPE, TABLE, { filters: EMPTY_FILTERS, sort: DEFAULT_SORT });
expect(window.localStorage.length).toBe(0);
});
it('без пользователя ничего не пишем и не читаем', () => {
writeStoredRefinement('', TABLE, { filters: { ...EMPTY_FILTERS, status: 'Черновик' }, sort: DEFAULT_SORT });
expect(window.localStorage.length).toBe(0);
expect(readStoredRefinement('', TABLE)).toBeUndefined();
});
});
@@ -0,0 +1,85 @@
import { REQUEST_STATUS_CHOICES } from '../../shared/approval';
import { DEFAULT_SORT, EMPTY_FILTERS, hasActiveFilters, isDefaultSort, ISortState, ITableFilters, SortKey } from './requestsTableFilters';
/**
* Фильтры и сортировка таблиц заявок в localStorage — чтобы не сбрасывались
* при обновлении страницы, переходе в «Поиск»/«Новую заявку» и возврате из
* заявки по ссылке. Своя запись на каждого пользователя и таблицу.
*
* Хранилище может быть недоступно (приватный режим, политика браузера) или
* содержать устаревшее/битое значение — тогда таблица работает как раньше, с
* чистыми фильтрами, а не падает. Любое сомнительное поле отбрасывается.
*/
export interface IStoredRefinement {
filters: ITableFilters;
sort: ISortState;
}
/** Версия формата: поменяется структура — старые записи просто не прочитаются. */
const STORAGE_PREFIX = 'rp-table-filters:v1';
const SORT_KEYS: SortKey[] = ['code', 'type', 'date', 'status'];
const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
function storageKey(scope: string, tableKey: string): string {
return `${STORAGE_PREFIX}:${scope.trim().toLowerCase()}:${tableKey}`;
}
function asDay(value: unknown): string {
return typeof value === 'string' && ISO_DAY.test(value) ? value : '';
}
/** Разобрать сохранённое значение; всё, что не прошло проверку, — как «не задано». */
export function parseStoredRefinement(raw: string | null): IStoredRefinement | undefined {
if (!raw) return undefined;
let parsed: { filters?: Record<string, unknown>; sort?: Record<string, unknown> };
try {
parsed = JSON.parse(raw);
} catch {
return undefined;
}
if (!parsed || typeof parsed !== 'object') return undefined;
const f = parsed.filters || {};
const typeId = typeof f.typeId === 'number' && isFinite(f.typeId) ? f.typeId : undefined;
const status = typeof f.status === 'string' && REQUEST_STATUS_CHOICES.indexOf(f.status) !== -1 ? f.status : '';
let dateFrom = asDay(f.dateFrom);
let dateTo = asDay(f.dateTo);
// Перевёрнутый период пустым списком выглядел бы как поломка — такой не восстанавливаем.
if (dateFrom && dateTo && dateFrom > dateTo) {
dateFrom = '';
dateTo = '';
}
const s = parsed.sort || {};
const sortKey = SORT_KEYS.indexOf(s.key as SortKey) !== -1 ? (s.key as SortKey) : undefined;
const direction = s.direction === 'asc' || s.direction === 'desc' ? s.direction : undefined;
const sort: ISortState = sortKey && direction ? { key: sortKey, direction } : DEFAULT_SORT;
return { filters: { ...EMPTY_FILTERS, typeId, status, dateFrom, dateTo }, sort };
}
export function readStoredRefinement(scope: string, tableKey: string): IStoredRefinement | undefined {
if (!scope) return undefined;
try {
return parseStoredRefinement(window.localStorage.getItem(storageKey(scope, tableKey)));
} catch {
return undefined;
}
}
/** Сохранить; состояние по умолчанию — удалить запись (сброс чистит хранилище). */
export function writeStoredRefinement(scope: string, tableKey: string, value: IStoredRefinement): void {
if (!scope) return;
try {
const key = storageKey(scope, tableKey);
if (!hasActiveFilters(value.filters) && isDefaultSort(value.sort)) {
window.localStorage.removeItem(key);
} else {
window.localStorage.setItem(key, JSON.stringify(value));
}
} catch {
// Хранилище недоступно — фильтры просто не запомнятся, работать это не мешает.
}
}
@@ -10,6 +10,7 @@ import {
SortKey, SortKey,
toggleSort, toggleSort,
} from './requestsTableFilters'; } from './requestsTableFilters';
import { readStoredRefinement, writeStoredRefinement } from './filtersStorage';
/** Фильтры и сортировка одной таблицы (см. requestsTableFilters.ts). */ /** Фильтры и сортировка одной таблицы (см. requestsTableFilters.ts). */
interface IRefinement { interface IRefinement {
@@ -22,6 +23,11 @@ const DEFAULT_REFINEMENT: IRefinement = { filters: EMPTY_FILTERS, sort: DEFAULT_
interface IOptions { interface IOptions {
/** Ключ таблицы: раздел (+ тип для «Все заявки»). */ /** Ключ таблицы: раздел (+ тип для «Все заявки»). */
tableKey: string; tableKey: string;
/**
* Чьи фильтры запоминать (email пользователя) — ключ записи в localStorage,
* чтобы на общем компьютере у двоих фильтры не смешивались. '' — не запоминать.
*/
storageScope: string;
/** Статус живёт в маршруте, а не здесь (вкладка «Инициированные»). */ /** Статус живёт в маршруте, а не здесь (вкладка «Инициированные»). */
statusFromRoute: boolean; statusFromRoute: boolean;
routeStatus: string; routeStatus: string;
@@ -51,16 +57,23 @@ export interface ITableRefinement {
* *
* СВОЁ у каждой таблицы: RequestsTableView один на все разделы, и без ключа по * СВОЁ у каждой таблицы: RequestsTableView один на все разделы, и без ключа по
* разделу фильтр «Тип: Отпуск» из «Моих заявок» переезжал бы в «На * разделу фильтр «Тип: Отпуск» из «Моих заявок» переезжал бы в «На
* согласовании». Живёт, пока смонтирована таблица: переживает открытие заявки * согласовании». Запоминается в localStorage (см. filtersStorage.ts): при первом
* и переходы между её разделами, но не уход на «Поиск»/«Новую заявку». * открытии таблицы берётся оттуда, каждое изменение туда пишется, сброс — удаляет
* запись. Так фильтры переживают обновление страницы и уход на «Поиск»/«Новую заявку».
* *
* Статус на «Инициированных» живёт в маршруте (так было и до фильтров) и * Статус на «Инициированных» живёт в маршруте (так было и до фильтров) и
* отбирается сервером — здесь его не дублируем, чтобы не было двух источников истины. * отбирается сервером — здесь его не дублируем, чтобы не было двух источников истины.
*/ */
export function useTableRefinement(options: IOptions): ITableRefinement { export function useTableRefinement(options: IOptions): ITableRefinement {
const { tableKey, statusFromRoute, routeStatus, onRouteStatusChange, page, onPageChange } = options; const { tableKey, storageScope, statusFromRoute, routeStatus, onRouteStatusChange, page, onPageChange } = options;
const [byTable, setByTable] = React.useState<Record<string, IRefinement>>({}); const [byTable, setByTable] = React.useState<Record<string, IRefinement>>({});
const refinement = byTable[tableKey] ?? DEFAULT_REFINEMENT; // Таблица ещё не трогалась в этом сеансе — состояние из хранилища. Читаем
// только тогда: дальше источник истины — byTable, а хранилище лишь его копия.
const stored = React.useMemo(
() => (byTable[tableKey] ? undefined : readStoredRefinement(storageScope, tableKey)),
[byTable, storageScope, tableKey]
);
const refinement = byTable[tableKey] ?? stored ?? DEFAULT_REFINEMENT;
const { sort } = refinement; const { sort } = refinement;
const localFilters: ITableFilters = statusFromRoute ? { ...refinement.filters, status: '' } : refinement.filters; const localFilters: ITableFilters = statusFromRoute ? { ...refinement.filters, status: '' } : refinement.filters;
@@ -73,6 +86,7 @@ export function useTableRefinement(options: IOptions): ITableRefinement {
// переход (onPageChange) ушёл бы от ещё старого маршрута и откатил бы статус. // переход (onPageChange) ушёл бы от ещё старого маршрута и откатил бы статус.
const save = (next: IRefinement, routeStatusChanged: boolean): void => { const save = (next: IRefinement, routeStatusChanged: boolean): void => {
setByTable((prev) => ({ ...prev, [tableKey]: next })); setByTable((prev) => ({ ...prev, [tableKey]: next }));
writeStoredRefinement(storageScope, tableKey, next);
if (!routeStatusChanged && page !== 1) onPageChange(1); if (!routeStatusChanged && page !== 1) onPageChange(1);
}; };
@@ -71,6 +71,7 @@ import { CandidateSection } from './sections/CandidateSection';
import { PpeSection } from './sections/PpeSection'; import { PpeSection } from './sections/PpeSection';
import { ConditionsSection } from './sections/ConditionsSection'; import { ConditionsSection } from './sections/ConditionsSection';
import { AgencySection } from './sections/AgencySection'; import { AgencySection } from './sections/AgencySection';
import { NoteSection } from './sections/NoteSection';
import { useStableCallback } from '../../shared/useStableCallback'; import { useStableCallback } from '../../shared/useStableCallback';
const RecruitingHireForm: React.FC<IRequestFormProps> = ({ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
@@ -443,6 +444,14 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
</CollapseSection> </CollapseSection>
)} )}
{/* Примечание — последним перед маршрутом: инициатор пишет его, уже
заполнив заявку, и видит рядом, кому оно уйдёт. */}
<FieldChangesProvider changes={toChangeMap(approverChanges)}>
<FormModeProvider readOnly={readOnly}>
<NoteSection {...sectionProps} />
</FormModeProvider>
</FieldChangesProvider>
<ApprovalMatrix <ApprovalMatrix
editor={matrix} editor={matrix}
search={(q) => approval.searchUsers(q)} search={(q) => approval.searchUsers(q)}
@@ -59,6 +59,8 @@ import { CandidateSection } from './sections/CandidateSection';
import { PpeSection } from './sections/PpeSection'; import { PpeSection } from './sections/PpeSection';
import { ConditionsSection } from './sections/ConditionsSection'; import { ConditionsSection } from './sections/ConditionsSection';
import { AgencySection } from './sections/AgencySection'; import { AgencySection } from './sections/AgencySection';
import { NoteSection } from './sections/NoteSection';
import { ApproverNoteBanner } from '../../shared/approverNote';
import { useStableCallback } from '../../shared/useStableCallback'; import { useStableCallback } from '../../shared/useStableCallback';
interface IRecruitingHireViewProps { interface IRecruitingHireViewProps {
@@ -374,6 +376,9 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
} }
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={status ? <StatusBadge status={status} /> : undefined}
> >
{/* Сохранённое значение, а не редактируемое: плашка — то, что написал
инициатор, и не должна меняться по ходу набора правок согласующим. */}
<ApproverNoteBanner text={savedData.HireApproverNote} important={savedData.HireApproverNoteImportant} />
<FieldChangesBanner changes={approverChanges} /> <FieldChangesBanner changes={approverChanges} />
<FieldChangesProvider changes={toChangeMap(approverChanges)}> <FieldChangesProvider changes={toChangeMap(approverChanges)}>
<FormModeProvider readOnly={!fullEditable}> <FormModeProvider readOnly={!fullEditable}>
@@ -388,6 +393,13 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
<div className="rp-no-print"> <div className="rp-no-print">
<HireAttachments editor={attachments} editable={fullEditable} /> <HireAttachments editor={attachments} editable={fullEditable} />
</div> </div>
{/* Тот же порядок, что в форме подачи: примечание — после вложений, перед маршрутом. */}
<FieldChangesProvider changes={toChangeMap(approverChanges)}>
<FormModeProvider readOnly={!fullEditable}>
<NoteSection {...sectionProps} />
</FormModeProvider>
</FieldChangesProvider>
</FormCard> </FormCard>
{routeError && <Alert variant="err">{routeError}</Alert>} {routeError && <Alert variant="err">{routeError}</Alert>}
@@ -25,7 +25,7 @@ const SCALAR_FIELDS: (keyof IFormData)[] = [
'HireCandidateName', 'HireHireDate', 'Position', 'HireCandidateName', 'HireHireDate', 'Position',
'Department', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod', 'ProbationPeriodOther', 'Department', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod', 'ProbationPeriodOther',
'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther', 'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther',
'AgencyName', 'AgencyTerms', 'AgencyName', 'AgencyTerms', 'HireApproverNote',
'SelectionRequestId', 'SelectionRequestListId', 'SelectionRequestCode' 'SelectionRequestId', 'SelectionRequestListId', 'SelectionRequestCode'
]; ];
@@ -62,6 +62,7 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise<
'ProjectCode', 'ProjectCode',
'RecruitingAgency', 'RecruitingAgency',
'HireOnArrival', 'HireOnArrival',
'HireApproverNoteImportant',
APPROVER_CHANGES_FIELD, APPROVER_CHANGES_FIELD,
'RequestStatus', 'RequestStatus',
'RequestUniqueCode' 'RequestUniqueCode'
@@ -78,6 +79,7 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise<
// Флаг агентства — булево поле (Yes/No), не строка. // Флаг агентства — булево поле (Yes/No), не строка.
(data as unknown as Record<string, unknown>).RecruitingAgency = !!item.RecruitingAgency; (data as unknown as Record<string, unknown>).RecruitingAgency = !!item.RecruitingAgency;
(data as unknown as Record<string, unknown>).HireOnArrival = !!item.HireOnArrival; (data as unknown as Record<string, unknown>).HireOnArrival = !!item.HireOnArrival;
data.HireApproverNoteImportant = !!item.HireApproverNoteImportant;
SCALAR_FIELDS.forEach((key) => { SCALAR_FIELDS.forEach((key) => {
const value = item[key]; const value = item[key];
@@ -79,6 +79,10 @@ export interface IFormData {
RecruitingAgency: boolean; RecruitingAgency: boolean;
AgencyName: string; AgencyName: string;
AgencyTerms: string; AgencyTerms: string;
// Примечание для согласующих (shared/approverNote): текст + «Важное».
// Префикс Hire — чтобы internalName не совпал с одноимёнными колонками других форм.
HireApproverNote: string;
HireApproverNoteImportant: boolean;
} }
/** Обновление одного поля формы с сохранением типа значения. */ /** Обновление одного поля формы с сохранением типа значения. */
@@ -119,7 +123,9 @@ export const EMPTY_DATA: IFormData = {
WorkScheduleOther: '', WorkScheduleOther: '',
RecruitingAgency: false, RecruitingAgency: false,
AgencyName: '', AgencyName: '',
AgencyTerms: '' AgencyTerms: '',
HireApproverNote: '',
HireApproverNoteImportant: false
}; };
/** Подпись флажка HireOnArrival рядом с датой приёма. */ /** Подпись флажка HireOnArrival рядом с датой приёма. */
@@ -44,6 +44,9 @@
{ "internalName": "RecruitingAgency", "title": "Рекрутинговое агентство", "type": "Boolean" }, { "internalName": "RecruitingAgency", "title": "Рекрутинговое агентство", "type": "Boolean" },
{ "internalName": "AgencyName", "title": "Название компании (агентство)", "type": "Text" }, { "internalName": "AgencyName", "title": "Название компании (агентство)", "type": "Text" },
{ "internalName": "AgencyTerms", "title": "Условия (агентство)", "type": "Note" } { "internalName": "AgencyTerms", "title": "Условия (агентство)", "type": "Note" },
{ "internalName": "HireApproverNote", "title": "Примечание для согласующих", "type": "Note" },
{ "internalName": "HireApproverNoteImportant", "title": "Примечание — важное", "type": "Boolean" }
] ]
} }
@@ -0,0 +1,22 @@
import * as React from 'react';
import { ApproverNoteSection, IApproverNoteFieldKeys } from '../../../shared/approverNote';
import { ISectionProps } from '../model';
/** Колонки примечания в списке данных приёма (см. provisioning.json). */
const NOTE_FIELD_KEYS: IApproverNoteFieldKeys = {
text: 'HireApproverNote',
important: 'HireApproverNoteImportant'
};
/** 6. Примечание для согласующих — общий раздел shared/approverNote на полях приёма. */
export const NoteSection: React.FC<ISectionProps> = ({ data, set }) => (
<ApproverNoteSection
title="6. Примечание для согласующих"
value={{ text: data.HireApproverNote, important: data.HireApproverNoteImportant }}
onChange={(note) => {
set('HireApproverNote', note.text);
set('HireApproverNoteImportant', note.important);
}}
fieldKeys={NOTE_FIELD_KEYS}
/>
);
@@ -0,0 +1,73 @@
@import '../../../../styles/design-tokens';
// Толщина цветной полосы слева у плашки: у важного примечания толще, чтобы
// отличалось не только цветом (дальтонизм, ч/б печать).
$banner-stripe: 3px;
$banner-stripe-important: 6px;
.banner {
display: flex;
align-items: flex-start;
gap: $sp-2;
width: 100%;
box-sizing: border-box;
margin-bottom: $sp-3;
padding: $sp-2 $sp-3;
border: 1px solid $gray-200;
border-left: $banner-stripe solid $navy-600;
border-radius: $r-sm;
background: $navy-50;
font-family: $font-sans;
color: $gray-900;
}
.bannerImportant {
border-color: $gold-300;
border-left-width: $banner-stripe-important;
border-left-color: $gold-600;
background: $gold-100;
.bannerIcon {
color: $gold-600;
}
}
.bannerIcon {
flex-shrink: 0;
margin-top: 2px;
color: $navy-600;
}
.bannerBody {
min-width: 0;
flex: 1;
}
.bannerTitle {
margin-bottom: 2px;
font-size: $fs-sm;
font-weight: 600;
}
.importantTag {
text-transform: uppercase;
color: $amber-700;
}
.noteText {
font-size: $fs-sm;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.link {
color: $navy-600;
text-decoration: underline;
}
.hint {
margin-top: $sp-1;
font-size: $fs-caption;
color: $gray-500;
}
@@ -0,0 +1,33 @@
import * as React from 'react';
import styles from './ApproverNote.module.scss';
import { Icon } from '../ui';
import { APPROVER_NOTE_TEXT } from './approverNoteTexts';
import { NoteText } from './NoteText';
interface Props {
text: string;
important: boolean;
}
/**
* Плашка «Примечание инициатора» над разделами заявки у согласующих и
* исполнителей — чтобы важное сообщение было видно без пролистывания формы.
* Пустое примечание — плашки нет. Важное выделено цветом и иконкой: если все
* примечания яркие, на них быстро перестают обращать внимание.
*/
export const ApproverNoteBanner: React.FC<Props> = ({ text, important }) => {
if (!text.trim()) return null;
return (
<div className={`${styles.banner} ${important ? styles.bannerImportant : ''}`} role="note">
<Icon name={important ? 'warning' : 'info'} size="sm" className={styles.bannerIcon} />
<div className={styles.bannerBody}>
<div className={styles.bannerTitle}>
{important && <span className={styles.importantTag}>{APPROVER_NOTE_TEXT.bannerImportantPrefix} · </span>}
{APPROVER_NOTE_TEXT.bannerTitle}
</div>
<NoteText text={text} />
</div>
</div>
);
};
@@ -0,0 +1,54 @@
import * as React from 'react';
import styles from './ApproverNote.module.scss';
import { CollapseSection, FormField, TextArea, Toggle, useFormReadOnly } from '../ui';
import { APPROVER_NOTE_TEXT } from './approverNoteTexts';
/** Примечание: текст + признак «Важное». */
export interface IApproverNote {
text: string;
important: boolean;
}
/** Ключи полей формы (= internalName колонок) — для подсветки правок и прокрутки к полю. */
export interface IApproverNoteFieldKeys {
text: string;
important: string;
}
interface Props {
/** Заголовок раздела с номером по порядку формы, напр. «6. Примечание для согласующих». */
title: string;
value: IApproverNote;
onChange: (value: IApproverNote) => void;
fieldKeys: IApproverNoteFieldKeys;
}
/**
* Раздел «Примечание для согласующих» — общий для форм. Сама форма решает, в
* какие колонки писать (fieldKeys) и где показывать плашку ApproverNoteBanner.
* В режиме чтения (FormModeProvider readOnly) поля только показываются.
*/
export const ApproverNoteSection: React.FC<Props> = ({ title, value, onChange, fieldKeys }) => {
const readOnly = useFormReadOnly();
return (
<CollapseSection title={title} icon="info">
<FormField label={APPROVER_NOTE_TEXT.fieldLabel} htmlFor={fieldKeys.text} full>
<TextArea
id={fieldKeys.text}
value={value.text}
onChange={(text) => onChange({ ...value, text })}
placeholder={APPROVER_NOTE_TEXT.placeholder}
/>
{!readOnly && <div className={styles.hint}>{APPROVER_NOTE_TEXT.fieldHint}</div>}
</FormField>
<FormField label={APPROVER_NOTE_TEXT.importantLabel} fieldKey={fieldKeys.important}>
<Toggle
checked={value.important}
onChange={(important) => onChange({ ...value, important })}
onLabel={APPROVER_NOTE_TEXT.importantOn}
/>
</FormField>
</CollapseSection>
);
};
@@ -0,0 +1,22 @@
import * as React from 'react';
import styles from './ApproverNote.module.scss';
import { linkify } from './linkify';
interface Props {
text: string;
}
/** Текст примечания: переносы строк сохраняются (CSS pre-wrap), ссылки кликабельны. */
export const NoteText: React.FC<Props> = ({ text }) => (
<div className={styles.noteText}>
{linkify(text).map((part, i) =>
part.href ? (
<a key={i} href={part.href} target="_blank" rel="noopener noreferrer" className={styles.link}>
{part.text}
</a>
) : (
<React.Fragment key={i}>{part.text}</React.Fragment>
)
)}
</div>
);
@@ -0,0 +1,10 @@
/** Пользовательские тексты «Примечания для согласующих» — в одном месте (задел под локализацию). */
export const APPROVER_NOTE_TEXT = {
fieldLabel: 'Примечание',
fieldHint: 'Увидят все согласующие и исполнители сразу при открытии заявки.',
placeholder: 'Важная информация для согласующих (необязательно)',
importantLabel: 'Важное',
importantOn: 'Да',
bannerTitle: 'Примечание инициатора',
bannerImportantPrefix: 'Важно',
} as const;
@@ -0,0 +1,5 @@
// «Примечание для согласующих» — общий для форм блок: раздел формы + плашка у согласующих.
export { ApproverNoteSection } from './ApproverNoteSection';
export type { IApproverNote, IApproverNoteFieldKeys } from './ApproverNoteSection';
export { ApproverNoteBanner } from './ApproverNoteBanner';
export { APPROVER_NOTE_TEXT } from './approverNoteTexts';
@@ -0,0 +1,32 @@
import { linkify } from './linkify';
describe('linkify', () => {
it('текст без ссылок — один кусок', () => {
expect(linkify('Оклад согласован устно.')).toEqual([{ text: 'Оклад согласован устно.' }]);
});
it('ссылка посреди текста, точка в конце предложения не входит в адрес', () => {
expect(linkify('Резюме: https://hh.kz/r/1. Спасибо')).toEqual([
{ text: 'Резюме: ' },
{ text: 'https://hh.kz/r/1', href: 'https://hh.kz/r/1' },
{ text: '. Спасибо' },
]);
});
it('несколько ссылок и переносы строк сохраняются как текст', () => {
expect(linkify('a http://x.kz\nb https://y.kz')).toEqual([
{ text: 'a ' },
{ text: 'http://x.kz', href: 'http://x.kz' },
{ text: '\nb ' },
{ text: 'https://y.kz', href: 'https://y.kz' },
]);
});
it('не-http схемы ссылкой не становятся', () => {
expect(linkify('javascript:alert(1)')).toEqual([{ text: 'javascript:alert(1)' }]);
});
it('пустая строка — пустой список', () => {
expect(linkify('')).toEqual([]);
});
});
@@ -0,0 +1,34 @@
/** Кусок текста примечания: обычный текст или ссылка. */
export interface ITextPart {
text: string;
/** Задан — кусок является ссылкой. */
href?: string;
}
// http(s)-ссылка до первого пробела. Другие схемы (javascript: и т.п.) не
// распознаются намеренно: текст пишет пользователь, в href он не должен
// протащить ничего, кроме обычной веб-ссылки.
const URL_PATTERN = /https?:\/\/[^\s]+/g;
// Знаки, которыми ссылку обычно заканчивает ПРЕДЛОЖЕНИЕ, а не сам адрес
// («см. https://x.kz/a.» — точка не часть адреса).
const TRAILING_PUNCTUATION = /[.,;:!?)»"']+$/;
/** Разбить текст на обычные куски и ссылки — для вывода с кликабельными ссылками. */
export function linkify(text: string): ITextPart[] {
const parts: ITextPart[] = [];
let last = 0;
let match: RegExpExecArray | null;
URL_PATTERN.lastIndex = 0;
while ((match = URL_PATTERN.exec(text)) !== null) {
const trailing = match[0].match(TRAILING_PUNCTUATION);
const url = trailing ? match[0].slice(0, -trailing[0].length) : match[0];
if (match.index > last) parts.push({ text: text.slice(last, match.index) });
parts.push({ text: url, href: url });
last = match.index + url.length;
}
if (last < text.length) parts.push({ text: text.slice(last) });
return parts;
}