From 71335b325e57337255b019c491ad3baf202daa30 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 10 Aug 2026 16:07:58 +0500 Subject: [PATCH] =?UTF-8?q?=D0=BA=D1=83=D1=87=D0=B0=20=D0=B4=D0=BE=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/RequestPortal.tsx | 35 +++-- .../myRequests/RequestsTableView.module.scss | 42 +----- .../features/myRequests/RequestsTableView.tsx | 45 +++--- .../myRequests/SearchView.module.scss | 42 +----- .../features/myRequests/SearchView.tsx | 21 +-- .../features/myRequests/myRequests.service.ts | 139 ++++++++++++------ .../approval/ui/StatusBadge.module.scss | 10 ++ .../shared/approval/ui/StatusBadge.tsx | 24 ++- .../requestPortal/shared/routing.test.ts | 55 ++++++- src/webparts/requestPortal/shared/routing.ts | 54 ++++++- 10 files changed, 277 insertions(+), 190 deletions(-) diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index b61289c..f049407 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import styles from './RequestPortal.module.scss'; import type { IRequestPortalProps } from './IRequestPortalProps'; import { IconSprite } from '../shared/ui/Icon/IconSprite'; -import { navigate, useRoute, NavSection, OwnTab } from '../shared/routing'; +import { navigate, persistOwnSelection, useRoute, NavSection, OwnTab } from '../shared/routing'; import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents'; import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges'; import { SideNav } from './SideNav/SideNav'; @@ -110,28 +110,27 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context // таблица на него подписана и перечитает данные фоном, не убирая с экрана // текущие строки (см. RequestsTableView/SearchView). if (nav === route.nav && route.mode === 'submit') emitRequestsChanged(); - // Возврат в «Мои заявки» открывает ту же подвкладку и с тем же фильтром, - // что были в прошлый раз, — иначе уход в другой раздел и обратно сбрасывал - // бы выбор пользователя. - navigate( - nav === 'own' - ? { mode: 'submit', nav, ownTab: route.ownTab, ownStatus: route.ownStatus } - : { mode: 'submit', nav } - ); + // Подвкладку/фильтр «Моих заявок» здесь передавать не нужно: при переходе + // ИЗ другого раздела route.ownTab всё равно undefined (parseRoute заполняет + // его только когда nav==='own'), а при возврате на уже открытую «own» их + // подхватит storage-фолбэк в parseRoute — то же самое значение, которое + // пользователь выбирал последним (см. persistOwnSelection/gotoOwnTab). + navigate({ mode: 'submit', nav }); }; - const gotoOwnTab = (ownTab: OwnTab): void => + const gotoOwnTab = (ownTab: OwnTab): void => { // Фильтр по статусу относится только к «Инициированным» — на других // подвкладках отбор задаёт сама вкладка, поэтому фильтр туда не тащим. - navigate({ - mode: 'submit', - nav: 'own', - ownTab, - ownStatus: ownTab === 'created' ? route.ownStatus : undefined, - }); + const ownStatus = ownTab === 'created' ? route.ownStatus : undefined; + persistOwnSelection(ownTab, ownStatus); + navigate({ mode: 'submit', nav: 'own', ownTab, ownStatus }); + }; - const setOwnStatus = (ownStatus: string): void => - navigate({ mode: 'submit', nav: 'own', ownTab: 'created', ownStatus: ownStatus || undefined }); + const setOwnStatus = (ownStatus: string): void => { + const normalized = ownStatus || undefined; + persistOwnSelection('created', normalized); + navigate({ mode: 'submit', nav: 'own', ownTab: 'created', ownStatus: normalized }); + }; // Клик по строке таблицы. СВОЯ заявка в незавершённом статусе (черновик, // возвращённая, отклонённая) открывается на редактирование, всё остальное — // на просмотр: чужую заявку править нельзя, а свою уже ушедшую по маршруту — diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss b/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss index 2e564df..fd77c0e 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss @@ -187,41 +187,7 @@ width: 190px; } -.status { - display: inline-block; - padding: 3px 10px; - border-radius: $r-md; - font-size: $fs-caption; - font-weight: 500; - line-height: 1.3; -} - -.statusDraft { - background: $gray-100; - color: $gray-700; -} - -.statusOnApproval { - background: $gold-100; - color: $gold-600; -} - -.statusApproved { - background: $green-50; - color: $green-600; -} - -.statusRejected { - background: $red-50; - color: $red-500; -} - -.statusReturned { - background: $blue-50; - color: $blue-600; -} - -.statusDefault { - background: $gray-100; - color: $gray-500; -} +// Раскраска статуса — теперь только в shared/approval/ui/StatusBadge (см. +// использование ниже с size="sm"). Раньше здесь была своя копия этих цветов — +// она разошлась с эталоном (На согласовании/Возвращена на доработку оказались +// перепутаны), поэтому копию убрали, а не подправили. diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index 1d50a5e..0ec7a21 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -7,11 +7,12 @@ import { getApprovalQueueIndex, getLinkedTypes, getOwnRequests, + getOwnRequestsByStatus, hydratePendingAuthors, searchOwnRequests, } from './myRequests.service'; import { IMyRequest } from './models/IMyRequest'; -import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType } from '../../shared/approval'; +import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType, StatusBadge } from '../../shared/approval'; import { onRequestsChanged } from '../../shared/requestEvents'; import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery'; import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache'; @@ -103,23 +104,6 @@ function formatDate(iso: string): string { return `${dd}.${mm}.${d.getFullYear()}`; } -function statusClass(status: string): string { - switch (status) { - case REQUEST_STATUS.approved: - return styles.statusApproved; - case REQUEST_STATUS.rejected: - return styles.statusRejected; - case REQUEST_STATUS.returned: - return styles.statusReturned; - case REQUEST_STATUS.onApproval: - return styles.statusOnApproval; - case REQUEST_STATUS.draft: - return styles.statusDraft; - default: - return styles.statusDefault; - } -} - // Таблица заявок. Клик в ЛЮБОМ месте строки открывает заявку (кликабельна вся // строка, а не только название), поэтому строка — с onClick + tabIndex // (доступ с клавиатуры: Enter/Space). @@ -173,7 +157,7 @@ const Table: React.FC<{ )} {columns.status && ( - {r.status || '—'} + {r.status ? : '—'} )} @@ -205,9 +189,12 @@ const Table: React.FC<{ // после фильтрации — нарезка на страницы и дочитывание автора // (hydratePendingAuthors) — тоже только для видимой страницы. // -// Отбор по статусу ЗАЯВКИ ('rework' и фильтр на 'own:created') уходит в тот же -// KQL отдельным условием (см. MP_TICKET_STATUS) — то есть работает вместе с -// текстовым поиском и с серверной постраничностью, без вычитывания архива. +// Отбор по статусу ЗАЯВКИ ('rework' и фильтр на 'own:created') — НЕ через KQL: +// managed property для произвольной Choice-колонки ненадёжна (см. +// getOwnRequestsByStatus в myRequests.service.ts — там объяснение и ссылка на +// реальный инцидент). Вместо этого Search ищет только по автору (надёжная +// AuthorOWSUSER), а статус фильтруется в памяти по полям, дочитанным через +// REST; постраничность там же, в памяти. // // Все варианты перечитываются по событию rp-requests-changed // (shared/requestEvents) — после создания заявки и после решения согласующего. @@ -263,13 +250,17 @@ export const RequestsTableView: React.FC = ({ const q = debouncedQuery.trim(); const fetchPage = async (): Promise => { - // Свои заявки: «Доработка» — тот же список, что «Инициированные», просто - // со статусом, зашитым в раздел, а не выбранным в фильтре. - if (view === 'rework' || view === 'own:created') { + // «Доработка» — тот же список, что «Инициированные», просто со статусом, + // зашитым в раздел, а не выбранным в фильтре. + if (view === 'rework' || (view === 'own:created' && statusFilter)) { const status = view === 'rework' ? REQUEST_STATUS.returned : statusFilter; + return getOwnRequestsByStatus(context, types, status, page, debouncedQuery); + } + + if (view === 'own:created') { return q.length > 0 - ? searchOwnRequests(context, types, debouncedQuery, page, status) - : getOwnRequests(context, types, page, status); + ? searchOwnRequests(context, types, debouncedQuery, page) + : getOwnRequests(context, types, page); } const queue = QUEUE_BY_VIEW[view]; diff --git a/src/webparts/requestPortal/features/myRequests/SearchView.module.scss b/src/webparts/requestPortal/features/myRequests/SearchView.module.scss index 6663a95..966ce9d 100644 --- a/src/webparts/requestPortal/features/myRequests/SearchView.module.scss +++ b/src/webparts/requestPortal/features/myRequests/SearchView.module.scss @@ -163,41 +163,7 @@ white-space: nowrap; } -.status { - display: inline-block; - padding: 3px 10px; - border-radius: $r-md; - font-size: $fs-caption; - font-weight: 500; - line-height: 1.3; -} - -.statusDraft { - background: $gray-100; - color: $gray-700; -} - -.statusOnApproval { - background: $gold-100; - color: $gold-600; -} - -.statusApproved { - background: $green-50; - color: $green-600; -} - -.statusRejected { - background: $red-50; - color: $red-500; -} - -.statusReturned { - background: $blue-50; - color: $blue-600; -} - -.statusDefault { - background: $gray-100; - color: $gray-500; -} +// Раскраска статуса — теперь только в shared/approval/ui/StatusBadge (см. +// использование ниже с size="sm"). Раньше здесь была своя копия этих цветов — +// она разошлась с эталоном (На согласовании/Возвращена на доработку оказались +// перепутаны), поэтому копию убрали, а не подправили. diff --git a/src/webparts/requestPortal/features/myRequests/SearchView.tsx b/src/webparts/requestPortal/features/myRequests/SearchView.tsx index a26e2ef..e6e3cbf 100644 --- a/src/webparts/requestPortal/features/myRequests/SearchView.tsx +++ b/src/webparts/requestPortal/features/myRequests/SearchView.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import type { WebPartContext } from '@microsoft/sp-webpart-base'; import { getLinkedTypes, searchMyRequests } from './myRequests.service'; import { IMyRequest } from './models/IMyRequest'; -import { REQUEST_STATUS, IRequestType } from '../../shared/approval'; +import { IRequestType, StatusBadge } from '../../shared/approval'; import { onRequestsChanged } from '../../shared/requestEvents'; import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery'; import { pageKey, pageSignature, readPage, writePage } from './pageCache'; @@ -32,23 +32,6 @@ function formatDate(iso: string): string { return `${dd}.${mm}.${d.getFullYear()}`; } -function statusClass(status: string): string { - switch (status) { - case REQUEST_STATUS.approved: - return styles.statusApproved; - case REQUEST_STATUS.rejected: - return styles.statusRejected; - case REQUEST_STATUS.returned: - return styles.statusReturned; - case REQUEST_STATUS.onApproval: - return styles.statusOnApproval; - case REQUEST_STATUS.draft: - return styles.statusDraft; - default: - return styles.statusDefault; - } -} - // В отличие от RequestsTableView (где по смыслу раздела нужно либо Автор, // либо Статус) — здесь смешаны «свои» и «где участвовал» заявки, поэтому // показываем обе колонки сразу, плюс небольшая метка «Автор/Согласование» — @@ -91,7 +74,7 @@ const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => voi {[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'} - {r.status || '—'} + {r.status ? : '—'} {r.kind === 'own' ? 'Создал(а) я' : 'Участвую'} diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 99b606c..47d4edf 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -41,20 +41,18 @@ const MP_APPROVER = 'TansuSiteApproverOWSUSER'; // Статус СТРОКИ МАРШРУТА (колонка ApprovalStatus списка «— согласование») — // Choice-поле, отсюда суффикс OWSCHCS (подтверждено отладочным перебором // 2026-08-06; у Person-полей он OWSUSER, см. MP_APPROVER). Колонку не путать с -// RequestStatus списка ДАННЫХ — это статус ЗАЯВКИ целиком, у него своя -// managed property (см. MP_TICKET_STATUS ниже). +// RequestStatus списка ДАННЫХ — это статус ЗАЯВКИ целиком. +// +// Для статуса ЗАЯВКИ (список ДАННЫХ) отдельной managed property здесь +// НАМЕРЕННО нет: имя для произвольной Choice-колонки в схеме поиска либо надо +// знать точно, либо угадывать по конвенции — угадали неверно (или колонка +// после переименования Status → RequestStatus ещё не переиндексирована), и +// фильтр по статусу молча возвращал 0 заявок. Поэтому статус заявки +// фильтруется НЕ в KQL, а в памяти после дочитывания реальных полей через REST +// (см. getOwnRequestsByStatus) — Search там ищет только по надёжному +// AuthorOWSUSER, статус ни при чём. const MP_STATUS = 'ApprovalStatusOWSCHCS'; -// То же самое для Choice-колонки «Статус» списка ДАННЫХ (внутреннее имя -// RequestStatus, см. forms/*/provisioning.json) — по ней отбираются -// «Доработка» и фильтр по статусу в «Моих заявках». Кириллица в значении -// проблемы не создаёт: KQL — не $filter (там она действительно подводит, см. -// indexApprovalsForType), и по MP_STATUS выше точно так же ищутся русские -// статусы строк маршрута. -// Если отбор по статусу вдруг начнёт стабильно возвращать 0 — проверять надо в -// первую очередь ЭТО имя в схеме поиска (см. предупреждение в searchOwnRequests). -const MP_TICKET_STATUS = 'RequestStatusOWSCHCS'; - export function currentUserEmail(context: WebPartContext): string { return (context.pageContext.user.email || '').toLowerCase(); } @@ -210,7 +208,7 @@ export async function getNavCounts(context: WebPartContext): Promise const [turn, rework] = await Promise.all([ buildPendingIndex(context, 'myTurn', MY_TURN_STATUSES), - getOwnRequests(context, types, 1, REQUEST_STATUS.returned), + getOwnRequestsByStatus(context, types, REQUEST_STATUS.returned, 1), ]); return { @@ -593,8 +591,7 @@ async function loadByIds( // Search — сырьё для довеска по свежести (см. getFreshOwnRowsForType). // Фильтруем только по AuthorId: статус в $filter не используем, потому что // кириллица в сравнении там уже подводила (см. indexApprovalsForType) — отбор -// по статусу делает вызывающая сторона. В самом Search кириллица проблемой не -// является, там статус уходит прямо в KQL (см. MP_TICKET_STATUS). +// по статусу делает вызывающая сторона. async function getOwnRowsForType(context: WebPartContext, type: IRequestType): Promise { try { const sp = spForSite(type.siteUrl as string, context); @@ -636,6 +633,39 @@ async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestTyp ); } +// Заявки автора, изменённые за последние RECENT_MS, — довесок к Search для +// getOwnRequestsByStatus. В отличие от getFreshOwnRowsForType (которая ловит +// НОВЫЕ заявки по дате СОЗДАНИЯ), здесь важна дата ИЗМЕНЕНИЯ: заявку могли +// вернуть на доработку неделю спустя после создания — по Created такую не +// найти, а Modified у неё обновится ровно в момент решения согласующего +// (см. approverDecision.service.ts/decide). Filter по Modified — обычная дата, +// не Choice-колонка, кириллицы в значении нет, поэтому $filter здесь надёжен +// (в отличие от статуса, см. getOwnRequestsByStatus). +async function getRecentlyChangedOwnRowsForType(context: WebPartContext, type: IRequestType): Promise { + try { + const sp = spForSite(type.siteUrl as string, context); + const me = await sp.web.currentUser.select('Id')(); + const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString(); + + const rows = (await sp.web + .getList(type.listId as string) + .items.filter(`AuthorId eq ${me.Id} and Modified ge '${cutoffIso}'`) + .select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title') + .expand('Author') + .top(OWN_REST_TOP)()) as Array>; + + return rows.map((r) => + toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own') + ); + } catch (e) { + console.error( + `[Текущие заявки] «${type.name}»: не удалось прочитать недавно изменённые заявки:`, + (e as Error).message + ); + return []; + } +} + // «Мои заявки» БЕЗ поиска — гибрид для масштаба: Search с серверной пагинацией // (RowLimit/StartRow) находит страницу без сканирования всего архива, REST по // «последним N минутам» добирает то, что Search мог не успеть проиндексировать @@ -644,20 +674,18 @@ async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestTyp export async function getOwnRequests( context: WebPartContext, types: IRequestType[], - page: number, - status: string = '' + page: number ): Promise { if (types.length === 0) return { rows: [], totalRows: 0 }; - const searchPage = await searchOwnRequests(context, types, '', page, status); + const searchPage = await searchOwnRequests(context, types, '', page); if (page !== 1) return searchPage; - // Довесок по свежести отбираем по статусу здесь: он приходит REST-ом, где - // сравнивать статус в $filter нельзя (кириллица), — зато набор маленький. - const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t)))) - .reduce((all, r) => all.concat(r), [] as IMyRequest[]) - .filter((r) => !status || r.status === status); + const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t)))).reduce( + (all, r) => all.concat(r), + [] as IMyRequest[] + ); const searchKeys = new Set(searchPage.rows.map((r) => `${r.requestTypeId}-${r.id}`)); const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`)); @@ -677,8 +705,7 @@ export async function searchOwnRequests( context: WebPartContext, types: IRequestType[], query: string, - page: number, - status: string = '' + page: number ): Promise { if (types.length === 0) return { rows: [], totalRows: 0 }; @@ -689,9 +716,6 @@ export async function searchOwnRequests( `(${types.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`, `AuthorOWSUSER:"${claimsLogin}"`, ]; - // Отбор по статусу заявки — на СЕРВЕРЕ, вместе с постраничностью: иначе - // пришлось бы вычитывать весь архив автора и резать его в памяти. - if (status) clauses.push(`${MP_TICKET_STATUS}:"${status}"`); const text = freeTextClause(query); if (text) clauses.push(text); const kql = clauses.join(' AND '); @@ -708,18 +732,6 @@ export async function searchOwnRequests( const totalRows = results.TotalRows || 0; - - // Отбор по статусу опирается на managed property, имя которой мы выводим по - // конвенции, а не читаем из схемы (см. MP_TICKET_STATUS). Если она названа - // иначе или не заведена, запрос отработает без ошибки и просто вернёт 0 — - // такое молчание слишком дорого отлаживать вслепую, поэтому подсказываем. - if (status && totalRows === 0) { - console.warn( - `[Мои заявки] По статусу «${status}» ничего не найдено. Если так на ЛЮБОМ статусе — ` + - `проверьте managed property «${MP_TICKET_STATUS}» в схеме поиска SharePoint.` - ); - } - const entries = hitsToEntries(results, types); const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map(); // Порядок задаёт релевантность поиска, а не порядок ответов REST. @@ -728,6 +740,51 @@ export async function searchOwnRequests( return { rows, totalRows }; } +// Свои заявки с конкретным статусом ЗАЯВКИ (не строки маршрута) — «Доработка» +// и фильтр по статусу на вкладке «Инициированные». +// +// Статус НЕ уходит в KQL: единственная надёжная managed property здесь — +// AuthorOWSUSER (стандартное поле автора), а для произвольной Choice-колонки +// имя managed property надо либо знать из схемы поиска, либо угадывать по +// конвенции — угадали неверно (или она ещё не переиндексирована после +// переименования колонки Status → RequestStatus), из-за чего фильтр по +// статусу молча возвращал 0. Поэтому здесь Search ищет ТОЛЬКО по автору +// (+ опционально текст) — это находит ВСЕ заявки автора вплоть до +// KQL_ID_LIMIT, — а отбор по статусу и постраничность делаются в памяти по +// уже дочитанным через REST реальным полям (см. searchOwnAll/loadByIds). +// Тот же принцип, что и в filterPendingByQuery: Search сужает набор по +// надёжному полю, остальное — на клиенте. +export async function getOwnRequestsByStatus( + context: WebPartContext, + types: IRequestType[], + status: string, + page: number, + query: string = '' +): Promise { + const all = await searchOwnAll(context, types, query); + const searchFiltered = all.filter((r) => r.status === status); + + // Довесок по недавним изменениям — только на первой странице и только без + // текстового поиска (см. getOwnRequests: тот же принцип — довесок относится + // к «показать всё, свежее по дате», а не к результату полнотекстового поиска). + let merged = searchFiltered; + if (page === 1 && !query.trim()) { + const freshRows = (await Promise.all(types.map((t) => getRecentlyChangedOwnRowsForType(context, t)))) + .reduce((acc, r) => acc.concat(r), [] as IMyRequest[]) + .filter((r) => r.status === status); + + const seen = new Set(searchFiltered.map((r) => `${r.requestTypeId}-${r.id}`)); + const onlyFresh = freshRows.filter((r) => !seen.has(`${r.requestTypeId}-${r.id}`)); + merged = onlyFresh.concat(searchFiltered); + } + + const sorted = merged.sort((a, b) => b.created.localeCompare(a.created)); + return { + rows: sorted.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), + totalRows: sorted.length, + }; +} + // «На согласовании»: список заявок уже отобран по строкам согласования // (getPendingApprovalIndex — там же определяется, чья сейчас очередь, // обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search @@ -791,7 +848,7 @@ export interface ISearchPage { // Как searchOwnRequests, но без $skip/$top — сразу весь найденный срез (до // KQL_ID_LIMIT). Только Search, без REST-подстраховки на свежесть. -async function searchOwnAll( +export async function searchOwnAll( context: WebPartContext, types: IRequestType[], query: string diff --git a/src/webparts/requestPortal/shared/approval/ui/StatusBadge.module.scss b/src/webparts/requestPortal/shared/approval/ui/StatusBadge.module.scss index a02d2e9..3753365 100644 --- a/src/webparts/requestPortal/shared/approval/ui/StatusBadge.module.scss +++ b/src/webparts/requestPortal/shared/approval/ui/StatusBadge.module.scss @@ -12,6 +12,16 @@ white-space: nowrap; } +// Компактный вариант — для ячейки таблицы (RequestsTableView/SearchView), +// повторяет размеры, что были у их собственной .status до объединения. +.sm { + padding: 3px 10px; + border-radius: $r-md; + font-size: $fs-caption; + font-weight: 600; + letter-spacing: normal; +} + .draft { background: $gray-100; color: $gray-700; diff --git a/src/webparts/requestPortal/shared/approval/ui/StatusBadge.tsx b/src/webparts/requestPortal/shared/approval/ui/StatusBadge.tsx index ff2096f..9f4d0cc 100644 --- a/src/webparts/requestPortal/shared/approval/ui/StatusBadge.tsx +++ b/src/webparts/requestPortal/shared/approval/ui/StatusBadge.tsx @@ -16,7 +16,25 @@ const VARIANT: Record = { [REQUEST_STATUS.closed]: styles.closed, }; -/** Цветная плашка статуса заявки (шапка формы / просмотра). */ -export const StatusBadge: React.FC<{ status: string }> = ({ status }) => ( - {status} +export interface IStatusBadgeProps { + status: string; + /** + * 'md' (по умолчанию) — шапка формы/просмотра заявки; 'sm' — компактный + * вариант для ячейки таблицы (RequestsTableView/SearchView). Цвет статуса + * не зависит от размера — единственный источник цвета: VARIANT выше. + */ + size?: 'sm' | 'md'; +} + +/** + * Цветная плашка статуса заявки — ЕДИНСТВЕННОЕ место, где статус превращается + * в цвет. У таблиц раньше была своя копия этой раскраски (statusClass в + * RequestsTableView.tsx/SearchView.tsx) — она разошлась с этой (на согласовании + * и возвращена на доработку оказались перепутаны местами), поэтому обе копии + * убраны в пользу общего компонента. + */ +export const StatusBadge: React.FC = ({ status, size = 'md' }) => ( + + {status} + ); diff --git a/src/webparts/requestPortal/shared/routing.test.ts b/src/webparts/requestPortal/shared/routing.test.ts index afecdb0..8e6e833 100644 --- a/src/webparts/requestPortal/shared/routing.test.ts +++ b/src/webparts/requestPortal/shared/routing.test.ts @@ -1,9 +1,11 @@ -import { IRoute, navigate, parseRoute, routeUrl } from './routing'; +import { IRoute, navigate, parseRoute, persistOwnSelection, routeUrl } from './routing'; -// buildQuery/navigate читают и пишут window.location через history API — -// сбрасываем адрес перед каждым тестом, чтобы они не зависели от порядка запуска. +// buildQuery/navigate читают и пишут window.location через history API, а +// подвкладка/фильтр «Моих заявок» — ещё и localStorage (см. persistOwnSelection) — +// сбрасываем оба перед каждым тестом, чтобы они не зависели от порядка запуска. beforeEach(() => { window.history.replaceState({}, '', '/'); + window.localStorage.clear(); }); describe('parseRoute', () => { @@ -124,6 +126,53 @@ describe('подвкладки и фильтр «Моих заявок»', () => expect(window.location.search).not.toContain('rptab'); expect(window.location.search).not.toContain('rpstatus'); }); +}); + +describe('persistOwnSelection — запоминание подвкладки/фильтра между визитами', () => { + it('без явного rptab в адресе подставляется запомненная подвкладка', () => { + persistOwnSelection('rejected', undefined); + expect(parseRoute('?rpnav=own').ownTab).toBe('rejected'); + }); + + it('без явного rpstatus подставляется запомненный фильтр (только на «Инициированных»)', () => { + persistOwnSelection('created', 'Черновик'); + expect(parseRoute('?rpnav=own').ownStatus).toBe('Черновик'); + // На вкладках с решениями запомненный фильтр ни при чём — там его нет по смыслу. + persistOwnSelection('approved', 'Черновик'); + expect(parseRoute('?rpnav=own').ownStatus).toBeUndefined(); + }); + + it('явный параметр в адресе имеет приоритет над запомненным значением', () => { + persistOwnSelection('rejected', undefined); + expect(parseRoute('?rpnav=own&rptab=executed').ownTab).toBe('executed'); + }); + + it('очистка фильтра persistOwnSelection убирает его и из последующих parseRoute', () => { + persistOwnSelection('created', 'Черновик'); + expect(parseRoute('?rpnav=own').ownStatus).toBe('Черновик'); + + persistOwnSelection('created', undefined); + expect(parseRoute('?rpnav=own').ownStatus).toBeUndefined(); + }); + + it('запомненное значение не протекает за пределы раздела «Мои заявки»', () => { + persistOwnSelection('rejected', undefined); + expect(parseRoute('?rpnav=approvals').ownTab).toBeUndefined(); + }); + + it('localStorage недоступен (например, приватный режим) — не роняет parseRoute', () => { + const original = window.localStorage.getItem; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- имитация приватного режима, где чтение localStorage бросает + (window.localStorage as any).getItem = (): never => { + throw new Error('SecurityError'); + }; + try { + expect(() => parseRoute('?rpnav=own')).not.toThrow(); + expect(parseRoute('?rpnav=own').ownTab).toBe('created'); + } finally { + window.localStorage.getItem = original; + } + }); it('round-trip для подвкладки решений', () => { navigate({ mode: 'submit', nav: 'own', ownTab: 'executed' }); diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index 65f3dca..1311d98 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -62,6 +62,54 @@ export interface IRoute { const ROUTE_CHANGE_EVENT = 'rp-route-change'; +// Подвкладка и фильтр «Моих заявок» переживают перезагрузку страницы и новый +// визит (localStorage), но НЕ более того — как и текст поиска в +// usePersistedQuery.ts, это просто «продолжить с того же места», а не +// синхронизация между вкладками/устройствами. +// +// Читаются здесь же, в parseRoute, а не отдельным хуком в компоненте: URL уже +// является единственным источником правды для ownTab/ownStatus (см. +// buildQuery — он не пишет значения по умолчанию в адрес), поэтому именно тут, +// где URL молчит, естественно спросить «а что было в прошлый раз». +// Пишутся ТОЛЬКО через persistOwnSelection — то есть только там, где +// пользователь СОЗНАТЕЛЬНО меняет вкладку/фильтр (см. gotoOwnTab/setOwnStatus +// в RequestPortal.tsx), а не при каждой навигации: иначе, например, просто +// открыв и закрыв заявку, можно было бы затереть запомненное значение +// значением по умолчанию. +const OWN_TAB_STORAGE_KEY = 'rp-own-tab'; +const OWN_STATUS_STORAGE_KEY = 'rp-own-status'; + +function readStoredOwnTab(): OwnTab { + try { + const raw = window.localStorage.getItem(OWN_TAB_STORAGE_KEY); + return raw === 'approved' || raw === 'rejected' || raw === 'executed' ? raw : 'created'; + } catch { + return 'created'; + } +} + +function readStoredOwnStatus(): string | undefined { + try { + return window.localStorage.getItem(OWN_STATUS_STORAGE_KEY) || undefined; + } catch { + return undefined; + } +} + +/** Запомнить подвкладку/фильтр «Моих заявок» — вызывать только при явном выборе пользователя. */ +export function persistOwnSelection(ownTab: OwnTab, ownStatus: string | undefined): void { + try { + window.localStorage.setItem(OWN_TAB_STORAGE_KEY, ownTab); + if (ownStatus) { + window.localStorage.setItem(OWN_STATUS_STORAGE_KEY, ownStatus); + } else { + window.localStorage.removeItem(OWN_STATUS_STORAGE_KEY); + } + } catch { + // localStorage недоступен (приватный режим и т.п.) — просто не сохраняем. + } +} + function parseNav(raw: string | undefined): NavSection { if ( raw === 'approvals' || @@ -76,8 +124,8 @@ function parseNav(raw: string | undefined): NavSection { } function parseOwnTab(raw: string | undefined): OwnTab { - if (raw === 'approved' || raw === 'rejected' || raw === 'executed') return raw; - return 'created'; + if (raw === 'approved' || raw === 'rejected' || raw === 'executed' || raw === 'created') return raw; + return readStoredOwnTab(); } /** Разобрать строку запроса (по умолчанию — из адреса страницы) в маршрут. */ @@ -96,7 +144,7 @@ export function parseRoute(search: string = window.location.search): IRoute { // Фильтр по статусу живёт только на вкладке «Инициированные»: у вкладок с // решениями отбор задаёт сама вкладка. const ownTab = nav === 'own' ? parseOwnTab(params.get('rptab') ?? undefined) : undefined; - const ownStatus = ownTab === 'created' ? params.get('rpstatus') ?? undefined : undefined; + const ownStatus = ownTab === 'created' ? params.get('rpstatus') ?? readStoredOwnStatus() : undefined; // Открытая заявка (панель поверх раздела) — при полном наборе параметров. // Раздел под панелью сохраняется целиком, вместе с подвкладкой и фильтром: