diff --git a/src/webparts/requestPortal/features/myRequests/ActiveFiltersBar.module.scss b/src/webparts/requestPortal/features/myRequests/ActiveFiltersBar.module.scss new file mode 100644 index 0000000..5e3d36b --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/ActiveFiltersBar.module.scss @@ -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; +} diff --git a/src/webparts/requestPortal/features/myRequests/ActiveFiltersBar.tsx b/src/webparts/requestPortal/features/myRequests/ActiveFiltersBar.tsx new file mode 100644 index 0000000..988c52c --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/ActiveFiltersBar.tsx @@ -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 = ({ filters, types, onChange, onReset, shown, total }) => { + const chips = activeFilterChips(filters, types); + if (chips.length === 0) return null; + + return ( +
+
+ Включены фильтры: +
+ {chips.map((chip) => ( + + {chip.label} + + + ))} +
+ +
+ {shown !== undefined && ( +
+ {total !== undefined && total !== shown ? `Показано ${shown} из ${total}` : `Показано ${shown}`} +
+ )} +
+ ); +}; diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index 16566f7..1ce1fa1 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -31,6 +31,8 @@ import { useRoutedQuery } from '../../shared/usePersistedQuery'; import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache'; import { Pager } from './Pager'; import { RequestsFilterBar } from './RequestsFilterBar'; +import { ActiveFiltersBar } from './ActiveFiltersBar'; +import { effectiveUserEmail } from '../../shared/impersonation'; import { RequestHistoryDialog } from './RequestHistoryDialog'; import { SortableHeader } from './SortableHeader'; import { filterRows, hasActiveFilters, ISortState, SortKey, sortRows } from './requestsTableFilters'; @@ -441,6 +443,7 @@ export const RequestsTableView: React.FC = ({ // нет — всё как раньше. const refinement = useTableRefinement({ tableKey: `${view}|${allRequestsType?.id ?? ''}`, + storageScope: effectiveUserEmail(context), statusFromRoute: view === 'own:created', routeStatus: statusFilter, onRouteStatusChange: onStatusFilterChange, @@ -454,6 +457,10 @@ export const RequestsTableView: React.FC = ({ // Листание страниц и смена фильтра его переиспользуют (заново по сети не // идём), а автообновление и rp-requests-changed перечитывают. const fullListRef = React.useRef<{ key: string; rows: IMyRequest[] } | undefined>(undefined); + // Сколько заявок в разделе БЕЗ фильтров — для «Показано 3 из 10» в строке + // включённых фильтров. undefined — неизвестно (постраничная загрузка или + // статус из маршрута уже сузил список на сервере): тогда пишем просто «Показано 3». + const [unfilteredTotal, setUnfilteredTotal] = React.useState(undefined); // Какой ИМЕННО таблице принадлежат лежащие в состоянии rows/totalRows. // Компонент один на все разделы (см. LIST_VIEWS в RequestPortal), поэтому при @@ -654,6 +661,11 @@ export const RequestsTableView: React.FC = ({ setRows(fresh.rows); 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); @@ -720,6 +732,18 @@ export const RequestsTableView: React.FC = ({ if (rows && page > totalPages) onPageChange(totalPages); }, [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 (
@@ -743,10 +767,21 @@ export const RequestsTableView: React.FC = ({ types={view === 'all' ? undefined : types} // Статус — там же, где его колонка: в остальных разделах он у всех строк один. showStatus={columnsFor(view, '').status} - canReset={refinement.canReset} + // При включённых фильтрах сброс — в строке ActiveFiltersBar («Сбросить все»); + // здесь кнопка остаётся только для случая «изменена одна сортировка». + canReset={refinement.canReset && !hasActiveFilters(refinement.barFilters)} onReset={refinement.onReset} /> + + {error &&
Не удалось загрузить заявки: {error}
} {deleteError &&
{deleteError}
} {!error && !loaded &&
Загрузка…
} diff --git a/src/webparts/requestPortal/features/myRequests/activeFilterChips.test.ts b/src/webparts/requestPortal/features/myRequests/activeFilterChips.test.ts new file mode 100644 index 0000000..c91efa7 --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/activeFilterChips.test.ts @@ -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'); + }); +}); diff --git a/src/webparts/requestPortal/features/myRequests/activeFilterChips.ts b/src/webparts/requestPortal/features/myRequests/activeFilterChips.ts new file mode 100644 index 0000000..c1ae72b --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/activeFilterChips.ts @@ -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; +} diff --git a/src/webparts/requestPortal/features/myRequests/filtersStorage.test.ts b/src/webparts/requestPortal/features/myRequests/filtersStorage.test.ts new file mode 100644 index 0000000..7fef7d4 --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/filtersStorage.test.ts @@ -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(); + }); +}); diff --git a/src/webparts/requestPortal/features/myRequests/filtersStorage.ts b/src/webparts/requestPortal/features/myRequests/filtersStorage.ts new file mode 100644 index 0000000..0fffbcc --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/filtersStorage.ts @@ -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; sort?: Record }; + 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 { + // Хранилище недоступно — фильтры просто не запомнятся, работать это не мешает. + } +} diff --git a/src/webparts/requestPortal/features/myRequests/useTableRefinement.ts b/src/webparts/requestPortal/features/myRequests/useTableRefinement.ts index aae08d3..06e06d7 100644 --- a/src/webparts/requestPortal/features/myRequests/useTableRefinement.ts +++ b/src/webparts/requestPortal/features/myRequests/useTableRefinement.ts @@ -10,6 +10,7 @@ import { SortKey, toggleSort, } from './requestsTableFilters'; +import { readStoredRefinement, writeStoredRefinement } from './filtersStorage'; /** Фильтры и сортировка одной таблицы (см. requestsTableFilters.ts). */ interface IRefinement { @@ -22,6 +23,11 @@ const DEFAULT_REFINEMENT: IRefinement = { filters: EMPTY_FILTERS, sort: DEFAULT_ interface IOptions { /** Ключ таблицы: раздел (+ тип для «Все заявки»). */ tableKey: string; + /** + * Чьи фильтры запоминать (email пользователя) — ключ записи в localStorage, + * чтобы на общем компьютере у двоих фильтры не смешивались. '' — не запоминать. + */ + storageScope: string; /** Статус живёт в маршруте, а не здесь (вкладка «Инициированные»). */ statusFromRoute: boolean; routeStatus: string; @@ -51,16 +57,23 @@ export interface ITableRefinement { * * СВОЁ у каждой таблицы: RequestsTableView один на все разделы, и без ключа по * разделу фильтр «Тип: Отпуск» из «Моих заявок» переезжал бы в «На - * согласовании». Живёт, пока смонтирована таблица: переживает открытие заявки - * и переходы между её разделами, но не уход на «Поиск»/«Новую заявку». + * согласовании». Запоминается в localStorage (см. filtersStorage.ts): при первом + * открытии таблицы берётся оттуда, каждое изменение туда пишется, сброс — удаляет + * запись. Так фильтры переживают обновление страницы и уход на «Поиск»/«Новую заявку». * * Статус на «Инициированных» живёт в маршруте (так было и до фильтров) и * отбирается сервером — здесь его не дублируем, чтобы не было двух источников истины. */ 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>({}); - 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 localFilters: ITableFilters = statusFromRoute ? { ...refinement.filters, status: '' } : refinement.filters; @@ -73,6 +86,7 @@ export function useTableRefinement(options: IOptions): ITableRefinement { // переход (onPageChange) ушёл бы от ещё старого маршрута и откатил бы статус. const save = (next: IRefinement, routeStatusChanged: boolean): void => { setByTable((prev) => ({ ...prev, [tableKey]: next })); + writeStoredRefinement(storageScope, tableKey, next); if (!routeStatusChanged && page !== 1) onPageChange(1); };