Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7488681f68 | |||
| f64aa909cd | |||
| 545db1aee6 |
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user