From 2ed5d5a7d343067b958c80c9a000014c504a3b41 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 10 Aug 2026 13:48:16 +0500 Subject: [PATCH 01/13] =?UTF-8?q?=D1=81=D0=BA=D0=B5=D0=BB=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B3=D0=BE=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/RequestPortal.tsx | 88 ++++++-- .../components/SideNav/SideNav.tsx | 15 +- .../features/myRequests/OwnTabs.module.scss | 40 ++++ .../features/myRequests/OwnTabs.tsx | 45 ++++ .../myRequests/RequestsTableView.module.scss | 23 ++ .../features/myRequests/RequestsTableView.tsx | 190 +++++++++++----- .../features/myRequests/models/IMyRequest.ts | 7 + .../features/myRequests/myRequests.service.ts | 207 ++++++++++++++---- .../requestPortal/shared/routing.test.ts | 75 +++++++ src/webparts/requestPortal/shared/routing.ts | 64 +++++- 10 files changed, 620 insertions(+), 134 deletions(-) create mode 100644 src/webparts/requestPortal/features/myRequests/OwnTabs.module.scss create mode 100644 src/webparts/requestPortal/features/myRequests/OwnTabs.tsx diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 0bfc468..b61289c 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 } from '../shared/routing'; +import { navigate, useRoute, NavSection, OwnTab } from '../shared/routing'; import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents'; import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges'; import { SideNav } from './SideNav/SideNav'; @@ -13,9 +13,10 @@ import { REQUEST_TYPES } from '../data/requests'; import SubmitFeature from '../features/submit/SubmitFeature'; import ApproveFeature from '../features/approve/ApproveFeature'; import EditFeature from '../features/edit/EditFeature'; -import { RequestsTableView } from '../features/myRequests/RequestsTableView'; +import { ListView, RequestsTableView } from '../features/myRequests/RequestsTableView'; +import { OwnTabs } from '../features/myRequests/OwnTabs'; import { SearchView } from '../features/myRequests/SearchView'; -import { getPendingApproval } from '../features/myRequests/myRequests.service'; +import { getNavCounts, INavCounts } from '../features/myRequests/myRequests.service'; import { IMyRequest } from '../features/myRequests/models/IMyRequest'; import { isEditableStatus } from '../shared/approval'; @@ -26,6 +27,16 @@ import { isEditableStatus } from '../shared/approval'; // 'approvals'/'own' — таблицы заявок. Клик по строке таблицы открывает заявку // в выезжающей панели ПОВЕРХ раздела (rpmode=approve&rptype=&rpid=), поэтому // раздел под ней сохраняется; та же ссылка приходит из потока Power Automate. +// Разделы, которые показывают таблицу заявок, и соответствующее ей +// представление. У 'own' оно зависит ещё и от подвкладки (см. ниже), поэтому +// здесь стоит заглушка — важен сам факт наличия ключа. +const LIST_VIEWS: Partial> = { + approvals: 'approvals', + execution: 'execution', + rework: 'rework', + own: 'own:created', +}; + const RequestPortal: React.FC = ({ hasTeamsContext, context }) => { const rootClasses = [styles.requestPortal, hasTeamsContext ? styles.teams : ''] .filter(Boolean) @@ -33,7 +44,7 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context const route = useRoute(); const [navCollapsed, setNavCollapsed] = React.useState(false); - const [approvalsCount, setApprovalsCount] = React.useState(undefined); + const [navCounts, setNavCounts] = React.useState(undefined); const [activeCategoryId, setActiveCategoryId] = React.useState(() => { if (route.mode === 'create' && route.formKey) { @@ -43,22 +54,21 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context return DEFAULT_CATEGORY_ID; }); - // Счётчик на пункте «На согласовании». Обновляется по тому же событию, что и - // сами таблицы (создание заявки, решение согласующего), и на монтировании - // (в т.ч. на обновление страницы). Когда сама вкладка «На согласовании» - // открыта, RequestsTableView сообщает точное число через onMyTurnCountChange - // (ниже) — это перезаписывает значение отсюда, чтобы не было гонки между - // двумя независимыми запросами одних и тех же данных. + // Счётчики на пунктах-очередях («На согласовании», «На исполнении», + // «Доработка»). Обновляются по тому же событию, что и сами таблицы (создание + // заявки, решение согласующего), и на монтировании (в т.ч. на обновление + // страницы). Все три приходят из одного вызова getNavCounts — он же следит, + // чтобы цифры не разошлись с содержимым вкладок. React.useEffect(() => { let cancelled = false; const load = (): void => { - getPendingApproval(context) - .then((r) => { - if (!cancelled) setApprovalsCount(r.totalRows); + getNavCounts(context) + .then((c) => { + if (!cancelled) setNavCounts(c); }) .catch(() => { - if (!cancelled) setApprovalsCount(undefined); + if (!cancelled) setNavCounts(undefined); }); }; @@ -100,8 +110,28 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context // таблица на него подписана и перечитает данные фоном, не убирая с экрана // текущие строки (см. RequestsTableView/SearchView). if (nav === route.nav && route.mode === 'submit') emitRequestsChanged(); - navigate({ mode: 'submit', nav }); + // Возврат в «Мои заявки» открывает ту же подвкладку и с тем же фильтром, + // что были в прошлый раз, — иначе уход в другой раздел и обратно сбрасывал + // бы выбор пользователя. + navigate( + nav === 'own' + ? { mode: 'submit', nav, ownTab: route.ownTab, ownStatus: route.ownStatus } + : { mode: 'submit', nav } + ); }; + + const gotoOwnTab = (ownTab: OwnTab): void => + // Фильтр по статусу относится только к «Инициированным» — на других + // подвкладках отбор задаёт сама вкладка, поэтому фильтр туда не тащим. + navigate({ + mode: 'submit', + nav: 'own', + ownTab, + ownStatus: ownTab === 'created' ? route.ownStatus : undefined, + }); + + const setOwnStatus = (ownStatus: string): void => + navigate({ mode: 'submit', nav: 'own', ownTab: 'created', ownStatus: ownStatus || undefined }); // Клик по строке таблицы. СВОЯ заявка в незавершённом статусе (черновик, // возвращённая, отклонённая) открывается на редактирование, всё остальное — // на просмотр: чужую заявку править нельзя, а свою уже ушедшую по маршруту — @@ -112,9 +142,14 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context nav: route.nav, formKey: request.formKey, itemId: request.id, + ownTab: route.ownTab, + ownStatus: route.ownStatus, }); - const closeDrawer = (): void => navigate({ mode: 'submit', nav: route.nav }); + // Закрывая панель, возвращаемся ровно в тот список, из которого её открыли — + // включая подвкладку и фильтр «Моих заявок». + const closeDrawer = (): void => + navigate({ mode: 'submit', nav: route.nav, ownTab: route.ownTab, ownStatus: route.ownStatus }); // Закрытие панели РЕДАКТИРОВАНИЯ: там открыта форма, и её содержимое живёт // только в памяти — уходя без сохранения, пользователь теряет правки. @@ -132,7 +167,11 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context setNavCollapsed((v) => !v)} /> @@ -185,12 +224,21 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context /> )} - {(route.nav === 'approvals' || route.nav === 'own') && ( + {route.nav === 'own' && ( + + )} + + {LIST_VIEWS[route.nav] !== undefined && ( )} diff --git a/src/webparts/requestPortal/components/SideNav/SideNav.tsx b/src/webparts/requestPortal/components/SideNav/SideNav.tsx index ba6119c..8f207bb 100644 --- a/src/webparts/requestPortal/components/SideNav/SideNav.tsx +++ b/src/webparts/requestPortal/components/SideNav/SideNav.tsx @@ -10,9 +10,13 @@ interface INavItem { icon: IconName; } +// Порядок неслучаен: сверху действие (создать), затем три очереди — то, что +// ждёт пользователя прямо сейчас, — и только потом архив и поиск. const ITEMS: INavItem[] = [ { id: 'new', title: 'Новая заявка', icon: 'plus' }, { id: 'approvals', title: 'На согласовании', icon: 'user-check' }, + { id: 'execution', title: 'На исполнении', icon: 'helmet' }, + { id: 'rework', title: 'Доработка', icon: 'edit' }, { id: 'own', title: 'Мои заявки', icon: 'clipboard' }, { id: 'search', title: 'Поиск', icon: 'search' }, ]; @@ -20,8 +24,8 @@ const ITEMS: INavItem[] = [ interface ISideNavProps { activeId: NavSection; onChange: (id: NavSection) => void; - /** Счётчик у пункта «На согласовании» (сколько ждут решения). */ - approvalsCount?: number; + /** Счётчики очередей: сколько заявок ждёт пользователя в каждом разделе. */ + counts?: Partial>; collapsed: boolean; onToggleCollapsed: () => void; } @@ -31,7 +35,7 @@ interface ISideNavProps { export const SideNav: React.FC = ({ activeId, onChange, - approvalsCount, + counts, collapsed, onToggleCollapsed, }) => { @@ -58,7 +62,8 @@ export const SideNav: React.FC = ({ {ITEMS.map((item) => { const isActive = item.id === activeId; const itemClasses = [styles.item, isActive ? styles.itemActive : ''].filter(Boolean).join(' '); - const showCount = item.id === 'approvals' && !!approvalsCount; + const count = counts?.[item.id]; + const showCount = !!count; return ( ); })} diff --git a/src/webparts/requestPortal/features/myRequests/OwnTabs.module.scss b/src/webparts/requestPortal/features/myRequests/OwnTabs.module.scss new file mode 100644 index 0000000..cfb5dac --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/OwnTabs.module.scss @@ -0,0 +1,40 @@ +@import '../../../../styles/design-tokens'; + +// Подвкладки «Моих заявок». Оформление намеренно повторяет CategoryTabs +// (вкладки категорий на подаче заявки) — это тот же по смыслу элемент +// «переключатель раздела», и разнобой в двух местах портала выглядел бы ошибкой. +.tabs { + display: flex; + flex-wrap: wrap; + align-items: stretch; + gap: $sp-2; + border-bottom: 1px solid $gray-200; + margin-bottom: $sp-4; +} + +.tab { + display: inline-flex; + align-items: center; + padding: 12px 16px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: $gray-500; + font-family: $font-sans; + font-size: 13px; + font-weight: 500; + letter-spacing: .2px; + white-space: nowrap; + cursor: pointer; + transition: color .18s, border-color .18s; + + &:hover { + color: $navy-800; + } +} + +.active { + color: $navy-900; + font-weight: 600; + border-bottom-color: $gold-500; +} diff --git a/src/webparts/requestPortal/features/myRequests/OwnTabs.tsx b/src/webparts/requestPortal/features/myRequests/OwnTabs.tsx new file mode 100644 index 0000000..c08a781 --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/OwnTabs.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import styles from './OwnTabs.module.scss'; +import { OwnTab } from '../../shared/routing'; + +interface IOwnTabItem { + id: OwnTab; + title: string; +} + +// Суффикс «мной» намеренно опущен: раздел уже называется «Мои заявки», и внутри +// него он повторялся бы в каждой вкладке. Первая вкладка — про авторство, +// остальные три — про решения пользователя в ЧУЖИХ маршрутах. +const ITEMS: IOwnTabItem[] = [ + { id: 'created', title: 'Инициированные' }, + { id: 'approved', title: 'Согласованные' }, + { id: 'rejected', title: 'Отклонённые' }, + { id: 'executed', title: 'Выполненные' }, +]; + +interface IOwnTabsProps { + activeId: OwnTab; + onChange: (id: OwnTab) => void; +} + +/** Горизонтальный переключатель подвкладок раздела «Мои заявки». */ +export const OwnTabs: React.FC = ({ activeId, onChange }) => ( + +); diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss b/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss index effc7d5..4ea8fa0 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss @@ -34,6 +34,29 @@ font-weight: 600; } +// Фильтр по статусу — только на вкладке «Инициированные». Стоит слева от поиска +// и прижат к нему: вместе это один блок «чем сузить список». +.statusFilter { + margin-left: auto; + padding: 9px 13px; + border: 1px solid $gray-300; + border-radius: $r-sm; + background: $white; + font-family: $font-sans; + font-size: $fs-sm; + color: $gray-900; + cursor: pointer; + transition: .18s; + + @include input-focus-gold; +} + +// Когда фильтр есть, отступ до края уже задан им (margin-left: auto) — иначе +// поиск сам отжимается вправо. +.statusFilter + .search { + margin-left: 0; +} + .search { width: 280px; max-width: 100%; diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index 935db89..1d50a5e 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -1,16 +1,17 @@ import * as React from 'react'; import type { WebPartContext } from '@microsoft/sp-webpart-base'; import { + ApprovalQueueKind, filterPendingByQuery, + getApprovalQueue, + getApprovalQueueIndex, getLinkedTypes, getOwnRequests, - getPendingApproval, - getPendingApprovalIndex, hydratePendingAuthors, searchOwnRequests, } from './myRequests.service'; import { IMyRequest } from './models/IMyRequest'; -import { REQUEST_STATUS, IRequestType } from '../../shared/approval'; +import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType } from '../../shared/approval'; import { onRequestsChanged } from '../../shared/requestEvents'; import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery'; import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache'; @@ -26,16 +27,72 @@ const PAGE_SIZE = 20; const AUTO_REFRESH_MS = 10 * 60 * 1000; +/** + * Что именно показывает таблица. Один компонент обслуживает все списки заявок + * портала, а различаются они только источником строк и набором колонок: + * + * 'approvals' | 'execution' — очереди действий (из строк маршрута); + * 'rework' — свои заявки, возвращённые на доработку; + * 'own:created' — свои заявки (+ фильтр по статусу); + * 'own:approved'|'own:rejected'|'own:executed' — история своих решений. + */ +export type ListView = + | 'approvals' + | 'execution' + | 'rework' + | 'own:created' + | 'own:approved' + | 'own:rejected' + | 'own:executed'; + +// Представления, которые строятся на строках маршрута пользователя, и их очередь +// в сервисе. Всё, чего здесь нет ('rework'/'own:created'), читается из списка +// данных по автору. +const QUEUE_BY_VIEW: { [key: string]: ApprovalQueueKind } = { + approvals: 'approvals', + execution: 'execution', + 'own:approved': 'approved', + 'own:rejected': 'rejected', + 'own:executed': 'executed', +}; + +const HEADINGS: Record = { + approvals: 'Ждут вашего согласования', + execution: 'Ждут вашего исполнения', + rework: 'Возвращены вам на доработку', + 'own:created': 'Инициированные мной', + 'own:approved': 'Согласованные мной', + 'own:rejected': 'Отклонённые мной', + 'own:executed': 'Выполненные мной', +}; + +const EMPTY_TEXTS: Record = { + approvals: 'Нет заявок, ждущих вашего согласования.', + execution: 'Нет заявок, ждущих вашего исполнения.', + rework: 'Нет заявок, возвращённых вам на доработку.', + 'own:created': 'Вы ещё не создавали заявок.', + 'own:approved': 'Вы ещё ничего не согласовали.', + 'own:rejected': 'Вы ещё ничего не отклоняли.', + 'own:executed': 'Вы ещё ничего не выполнили.', +}; + +// Колонки: «Автор» нужен там, где заявка чужая; «Статус» — где он не следует из +// самого раздела. В истории решений важно и то, и другое: заявка чужая, а её +// текущий статус мог уйти дальше вашего решения. +function columnsFor(view: ListView): { author: boolean; status: boolean } { + if (view === 'approvals' || view === 'execution') return { author: true, status: false }; + if (view === 'rework' || view === 'own:created') return { author: false, status: true }; + return { author: true, status: true }; +} + export interface IRequestsTableViewProps { context: WebPartContext; - /** 'approvals' — ждут решения текущего пользователя; 'own' — созданные им. */ - kind: 'approvals' | 'own'; + view: ListView; + /** Фильтр по статусу заявки; только для view='own:created'. '' — все статусы. */ + statusFilter?: string; + onStatusFilterChange?: (status: string) => void; /** Клик по строке — открыть заявку (в выезжающей панели). */ onOpen: (request: IMyRequest) => void; - /** Точное число «ждут решения СЕЙЧАС» (kind='approvals', без поиска) — для - * счётчика в SideNav, чтобы не гонять для него отдельный параллельный - * запрос, который мог бы разойтись по времени с тем, что видно в таблице. */ - onMyTurnCountChange?: (count: number) => void; } function formatDate(iso: string): string { @@ -72,9 +129,11 @@ function statusClass(status: string): string { // «own»: автор всегда текущий пользователь, зато важен статус. const Table: React.FC<{ rows: IMyRequest[]; - kind: 'approvals' | 'own'; + view: ListView; onOpen: (request: IMyRequest) => void; -}> = ({ rows, kind, onOpen }) => ( +}> = ({ rows, view, onOpen }) => { + const columns = columnsFor(view); + return (
@@ -83,8 +142,8 @@ const Table: React.FC<{ - {kind === 'approvals' && } - {kind === 'own' && } + {columns.author && } + {columns.status && } @@ -107,12 +166,12 @@ const Table: React.FC<{ - {kind === 'approvals' && ( + {columns.author && ( )} - {kind === 'own' && ( + {columns.status && ( @@ -122,7 +181,8 @@ const Table: React.FC<{
Тип Название ДатаАвторСтатусАвторСтатус
{r.requestTypeName} {r.title || `#${r.id}`} {formatDate(r.created)} {[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'} {r.status || '—'}
-); + ); +}; // Раздел с таблицей заявок: поиск по всем полям + вывод постранично. // @@ -133,40 +193,44 @@ const Table: React.FC<{ // результат мог быть неустойчивым (заявка то находилась, то нет, в зависимости // от того, попала ли она в окно подстраховки); эту гибридную логику убрали. // -// 'own' без поиска — getOwnRequests (REST по AuthorId, страница режется в -// памяти). 'own' с поиском — searchOwnRequests (Search, RowLimit/StartRow). +// 'own:created' без фильтра и поиска — getOwnRequests (Search + REST-довесок на +// свежесть). С поиском — searchOwnRequests (Search, RowLimit/StartRow). // -// 'approvals' без поиска — getPendingApproval, REST по ApproverId(+Status), -// страница нарезается на СЕРВЕРЕ (сам берёт нужный срез индекса и дочитывает -// автора только для него). -// 'approvals' с поиском — область расширяется до «где пользователь вообще есть -// в маршруте» (getPendingApprovalIndex, ПОЛНЫЙ список без автора — постранично -// его тут не взять, т.к. фильтрация текстом идёт уже после), затем текстовый -// фильтр через Search (filterPendingByQuery), и только после фильтрации — -// нарезка на страницы и дочитывание автора (hydratePendingAuthors) — тоже -// только для видимой страницы, а не для всего отфильтрованного набора. +// Очереди и история решений ('approvals'/'execution'/'own:*' кроме created) без +// поиска — getApprovalQueue: REST по ApproverId(+Status), страница нарезается на +// СЕРВЕРЕ (сам берёт нужный срез индекса и дочитывает автора только для него). +// С поиском — getApprovalQueueIndex даёт ПОЛНЫЙ список этой же очереди без +// автора (постранично его тут не взять, т.к. фильтрация текстом идёт уже +// после), затем текстовый фильтр через Search (filterPendingByQuery), и только +// после фильтрации — нарезка на страницы и дочитывание автора +// (hydratePendingAuthors) — тоже только для видимой страницы. // -// Оба варианта перечитываются по событию rp-requests-changed +// Отбор по статусу ЗАЯВКИ ('rework' и фильтр на 'own:created') уходит в тот же +// KQL отдельным условием (см. MP_TICKET_STATUS) — то есть работает вместе с +// текстовым поиском и с серверной постраничностью, без вычитывания архива. +// +// Все варианты перечитываются по событию rp-requests-changed // (shared/requestEvents) — после создания заявки и после решения согласующего. // Поиск идёт по индексу, поэтому свежая правка может проявиться не мгновенно. export const RequestsTableView: React.FC = ({ context, - kind, + view, + statusFilter = '', + onStatusFilterChange, onOpen, - onMyTurnCountChange, }) => { const [types, setTypes] = React.useState(undefined); const [rows, setRows] = React.useState(undefined); const [totalRows, setTotalRows] = React.useState(0); const [page, setPage] = React.useState(1); - const [query, setQuery] = usePersistedQuery(kind); + const [query, setQuery] = usePersistedQuery(view); const [error, setError] = React.useState(''); // query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что // уходит в запрос (после паузы в наборе). Всё ниже завязано на debouncedQuery, // иначе поиск бил бы запросом по каждой набранной букве. const debouncedQuery = useDebouncedQuery(query); - React.useEffect(() => setPage(1), [debouncedQuery, kind]); + React.useEffect(() => setPage(1), [debouncedQuery, view, statusFilter]); React.useEffect(() => { let cancelled = false; @@ -185,7 +249,7 @@ export const RequestsTableView: React.FC = ({ React.useEffect(() => { if (types === undefined) return; let cancelled = false; - const cacheKey = pageKey('table', [kind, debouncedQuery.trim(), page]); + const cacheKey = pageKey('table', [view, statusFilter, debouncedQuery.trim(), page]); // Сразу показываем то, что уже загружали для ЭТОЙ вкладки (и этого запроса // и страницы), а обновление идёт фоном. Компонент один на обе вкладки — @@ -196,23 +260,29 @@ export const RequestsTableView: React.FC = ({ setRows(cached ? cached.rows : undefined); setTotalRows(cached ? cached.totalRows : 0); + const q = debouncedQuery.trim(); + const fetchPage = async (): Promise => { - if (kind === 'own') { - return debouncedQuery.trim().length > 0 - ? searchOwnRequests(context, types, debouncedQuery, page) - : getOwnRequests(context, types, page); + // Свои заявки: «Доработка» — тот же список, что «Инициированные», просто + // со статусом, зашитым в раздел, а не выбранным в фильтре. + if (view === 'rework' || view === 'own:created') { + const status = view === 'rework' ? REQUEST_STATUS.returned : statusFilter; + return q.length > 0 + ? searchOwnRequests(context, types, debouncedQuery, page, status) + : getOwnRequests(context, types, page, status); } - if (debouncedQuery.trim().length === 0) { + const queue = QUEUE_BY_VIEW[view]; + if (q.length === 0) { // Без поиска — страница уже нарезана и дочитана сервером. - const r = await getPendingApproval(context, 'myTurn', page); + const r = await getApprovalQueue(context, queue, page); return { rows: r.rows, totalRows: r.totalRows }; } - // С поиском — сначала полный список «где я есть в маршруте» (без - // автора), фильтруем текстом, и только потом режем на страницы и - // дочитываем автора — только для видимой страницы. - const idx = await getPendingApprovalIndex(context, 'involved'); + // С поиском — сначала полный список этой очереди (без автора), фильтруем + // текстом, и только потом режем на страницы и дочитываем автора — только + // для видимой страницы. + const idx = await getApprovalQueueIndex(context, queue); const filtered = await filterPendingByQuery(context, idx.types, idx.rows, debouncedQuery); const pageRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); await hydratePendingAuthors(context, idx.types, pageRows); @@ -234,9 +304,6 @@ export const RequestsTableView: React.FC = ({ setTotalRows(fresh.totalRows); } setError(''); - if (onMyTurnCountChange && kind === 'approvals' && debouncedQuery.trim().length === 0) { - onMyTurnCountChange(fresh.totalRows); - } } catch (e) { if (!cancelled) setError((e as Error).message); } @@ -256,23 +323,36 @@ export const RequestsTableView: React.FC = ({ unsubscribe(); window.clearInterval(timer); }; - }, [context, types, kind, debouncedQuery, page]); + }, [context, types, view, statusFilter, debouncedQuery, page]); - // Обе ветки теперь отдают уже нарезанную страницу — просто показываем как есть. + // Все ветки отдают уже нарезанную страницу — просто показываем как есть. const q = debouncedQuery.trim(); const total = totalRows; const visible = rows || []; const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); - const heading = kind === 'approvals' ? 'Ждут вашего решения' : 'Мои заявки'; - return (

- {heading} + {HEADINGS[view]} {rows && {total}}

+ {view === 'own:created' && onStatusFilterChange && ( + + )} = ({ {!error && rows === undefined &&
Загрузка…
} {!error && rows && total === 0 && (
- {q - ? `По запросу «${debouncedQuery}» ничего не найдено.` - : kind === 'approvals' - ? 'Нет заявок, ждущих вашего решения.' - : 'Вы ещё не создавали заявок.'} + {q ? `По запросу «${debouncedQuery}» ничего не найдено.` : EMPTY_TEXTS[view]}
)} {!error && rows && total > 0 && ( <> - +
)} diff --git a/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts index 31d1cb8..4630ccc 100644 --- a/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts +++ b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts @@ -15,4 +15,11 @@ export interface IMyRequest { formKey: string; /** 'own' — заявка, созданная текущим пользователем; 'approval' — ждёт его решения. */ kind: 'own' | 'approval'; + /** + * Статус СВОЕЙ строки маршрута (не заявки) — «На согласовании», «Согласовано» + * и т.п. Заполняется только у kind='approval'. Нужен, чтобы разнести заявки + * по очередям, не сканируя списки повторно (см. getMyTurnCounts). В таблицах + * не показывается — поэтому и не входит в pageSignature. + */ + myStepStatus?: string; } diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 3b56a79..39f33e9 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -42,6 +42,15 @@ const MP_APPROVER = 'TansuSiteApproverOWSUSER'; // отладочным перебором 2026-08-06; у Person-полей он OWSUSER, см. MP_APPROVER). const MP_STATUS = 'RequestStatusOWSCHCS'; +// То же самое для Choice-колонки «Статус» списка ДАННЫХ (внутреннее имя Status, +// см. forms/*/provisioning.json) — по ней отбираются «Доработка» и фильтр по +// статусу в «Моих заявках». Кириллица в значении проблемы не создаёт: KQL — не +// $filter (там она действительно подводит, см. indexApprovalsForType), и по +// MP_STATUS выше точно так же ищутся русские статусы строк маршрута. +// Если отбор по статусу вдруг начнёт стабильно возвращать 0 — проверять надо в +// первую очередь ЭТО имя в схеме поиска (см. предупреждение в searchOwnRequests). +const MP_TICKET_STATUS = 'StatusOWSCHCS'; + export function currentUserEmail(context: WebPartContext): string { return (context.pageContext.user.email || '').toLowerCase(); } @@ -60,6 +69,31 @@ export async function getLinkedTypes(context: WebPartContext): Promise = { + approvals: [REQUEST_STATUS.onApproval], + execution: [REQUEST_STATUS.onExecution], + approved: [REQUEST_STATUS.approved], + rejected: [REQUEST_STATUS.rejected], + executed: [REQUEST_STATUS.executed], +}; + +// Статусы строки, означающие «ход за пользователем» — для счётчиков в меню +// (обе очереди считаются за один проход, см. getMyTurnCounts). +const MY_TURN_STATUSES: string[] = [REQUEST_STATUS.onApproval, REQUEST_STATUS.onExecution]; + // --------------------------------------------------------------------------- // «На согласовании» — раньше сюда приходилось читать ВЕСЬ список согласования // (иначе на клиенте нечем было посчитать «чья очередь»). Теперь очередь @@ -93,13 +127,14 @@ const PENDING_PAGE_SIZE = 20; // см. filterPendingByQuery и вызывающую сторону в RequestsTableView). async function buildPendingIndex( context: WebPartContext, - scope: ApprovalScope + scope: ApprovalScope, + statuses: string[] ): Promise<{ types: IRequestType[]; index: IMyRequest[]; debug: IMyRequestsTypeDebug[] }> { const types = await getLinkedTypes(context); const tokenEmail = currentUserEmail(context); const perType = await Promise.all( - types.map((t) => indexApprovalsForType(context, t, tokenEmail, scope)) + types.map((t) => indexApprovalsForType(context, t, tokenEmail, scope, statuses)) ); // Свежие сверху — типы объединяются в один список, сортировать нужно после слияния. @@ -112,12 +147,12 @@ async function buildPendingIndex( // Без текстового поиска: страница нарезается ЗДЕСЬ (сервер решает, какие 20 // заявок отдать), автор дочитывается только для них. -export async function getPendingApproval( +export async function getApprovalQueue( context: WebPartContext, - scope: ApprovalScope = 'myTurn', + kind: ApprovalQueueKind, page: number = 1 ): Promise { - const { types, index, debug } = await buildPendingIndex(context, scope); + const { types, index, debug } = await buildPendingIndex(context, 'myTurn', QUEUE_STATUSES[kind]); const pageRows = index.slice((page - 1) * PENDING_PAGE_SIZE, page * PENDING_PAGE_SIZE); await hydrateAuthors(context, types, pageRows); @@ -128,14 +163,59 @@ export async function getPendingApproval( // С текстовым поиском: полный индекс (без автора) — фильтрует его вызывающая // сторона через filterPendingByQuery, а страницу и автора берёт из уже // отфильтрованного результата (см. hydratePendingAuthors). +// +// Область поиска — та же, что и у самой вкладки (статусы очереди уходят в KQL): +// раньше при вводе текста «На согласовании» расширялась до «где я вообще есть в +// маршруте», но теперь для сквозного поиска есть отдельный раздел «Поиск», а +// внутри вкладки предсказуемее искать по тому же набору, который в ней виден. +export async function getApprovalQueueIndex( + context: WebPartContext, + kind: ApprovalQueueKind +): Promise<{ rows: IMyRequest[]; types: IRequestType[]; debug: IMyRequestsTypeDebug[] }> { + const { types, index, debug } = await buildPendingIndex(context, 'involved', QUEUE_STATUSES[kind]); + return { rows: index, types, debug }; +} + +// Полный индекс «где я вообще есть в маршруте», без отбора по статусу — нужен +// только разделу «Поиск» (см. searchMyRequests). export async function getPendingApprovalIndex( context: WebPartContext, scope: ApprovalScope ): Promise<{ rows: IMyRequest[]; types: IRequestType[]; debug: IMyRequestsTypeDebug[] }> { - const { types, index, debug } = await buildPendingIndex(context, scope); + const { types, index, debug } = await buildPendingIndex(context, scope, []); return { rows: index, types, debug }; } +/** Счётчики очередей для бокового меню. */ +export interface INavCounts { + approvals: number; + execution: number; + rework: number; +} + +// «На согласовании» и «На исполнении» считаются за ОДИН проход: разделять их по +// отдельным запросам значило бы дважды просканировать одни и те же списки +// согласования. Статус своей строки приходит в myStepStatus (см. +// indexApprovalsForType), по нему и разносим. +// +// «Доработка» живёт в других списках (данные, по автору), поэтому считается +// своим запросом — но параллельно, и ровно тем же вызовом, что наполняет саму +// вкладку: иначе счётчик и список могли бы разойтись. +export async function getNavCounts(context: WebPartContext): Promise { + const types = await getLinkedTypes(context); + + const [turn, rework] = await Promise.all([ + buildPendingIndex(context, 'myTurn', MY_TURN_STATUSES), + getOwnRequests(context, types, 1, REQUEST_STATUS.returned), + ]); + + return { + approvals: turn.index.filter((r) => r.myStepStatus === REQUEST_STATUS.onApproval).length, + execution: turn.index.filter((r) => r.myStepStatus === REQUEST_STATUS.onExecution).length, + rework: rework.totalRows, + }; +} + // Публичная обёртка над hydrateAuthors — для дочитывания автора по уже // отфильтрованной и нарезанной на страницу выборке (см. getPendingApprovalIndex). export async function hydratePendingAuthors( @@ -156,7 +236,8 @@ async function indexApprovalsForType( context: WebPartContext, type: IRequestType, tokenEmail: string, - scope: ApprovalScope + scope: ApprovalScope, + statuses: string[] ): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug }> { const debug: IMyRequestsTypeDebug = { requestTypeId: type.id, @@ -211,20 +292,15 @@ async function indexApprovalsForType( .select('Id', 'TicketId', 'RequestStatus') .top(2000)()) as Array>; - // Статусы уходят в KQL: сервер сразу отдаёт только активные строки, + // Статусы уходят в KQL: сервер сразу отдаёт только нужные строки, // а не всё, где пользователь есть в маршруте. - const searchRows = await getInvolvedRowsViaSearch(context, type, approvalList, [ - REQUEST_STATUS.onApproval, - REQUEST_STATUS.onExecution, - ]); + const searchRows = await getInvolvedRowsViaSearch(context, type, approvalList, statuses); const seenIds = new Set(recentRows.map((r) => r.Id)); const allRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id))); - stepRows = allRows.filter( - (r) => - r.RequestStatus === REQUEST_STATUS.onApproval || - r.RequestStatus === REQUEST_STATUS.onExecution - ); + // REST-довесок берётся по Modified без учёта статуса (кириллица в $filter + // ненадёжна, см. выше) — отбор по статусу делаем здесь, на клиенте. + stepRows = allRows.filter((r) => statuses.indexOf(r.RequestStatus as string) !== -1); } else { // 'involved' — используется ТОЛЬКО для текстового поиска (см. // getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная @@ -233,7 +309,7 @@ async function indexApprovalsForType( // давал неустойчивый результат (заявка то находилась, то нет, в // зависимости от того, успел ли Search её проиндексировать к моменту // REST-окна). - stepRows = await getInvolvedRowsViaSearch(context, type, approvalList); + stepRows = await getInvolvedRowsViaSearch(context, type, approvalList, statuses); } debug.approvalRowCount = stepRows.length; @@ -243,6 +319,18 @@ async function indexApprovalsForType( const ticketIds = Array.from( new Set(stepRows.map((r) => r.TicketId as number | undefined).filter((id): id is number => !!id)) ); + + // Статус СВОЕЙ строки по заявке — нужен, чтобы разнести заявки по очередям + // одним проходом (см. getMyTurnCounts). Если строк по заявке несколько, + // берём первую подошедшую: все они уже отобраны по statuses, а очередь у + // заявки на практике одна. + const stepStatusByTicket = new Map(); + stepRows.forEach((r) => { + const ticketId = r.TicketId as number | undefined; + if (ticketId && !stepStatusByTicket.has(ticketId)) { + stepStatusByTicket.set(ticketId, (r.RequestStatus as string) || ''); + } + }); if (ticketIds.length === 0) { debug.approvalPendingTickets = 0; return { rows: [], debug }; @@ -256,7 +344,10 @@ async function indexApprovalsForType( .select('Id', 'Title', 'Status', 'Created') .top(ticketIds.length)()) as Array>; - const rows = ticketRows.map((r) => toMyRequest(r, undefined, type, 'approval')); + const rows = ticketRows.map((r) => ({ + ...toMyRequest(r, undefined, type, 'approval'), + myStepStatus: stepStatusByTicket.get(r.Id as number), + })); debug.approvalPendingTickets = rows.length; return { rows, debug }; @@ -494,19 +585,17 @@ async function loadByIds( return byKey; } -// Свежие (созданные за последние RECENT_MS) заявки автора — обычным REST-ом, -// в обход Search (индекс мог их ещё не подхватить). Без текстового запроса — -// матчить название не нужно, отдаём как есть. -async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestType): Promise { +// Последние OWN_REST_TOP заявок автора одного типа обычным REST-ом, в обход +// 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); const me = await sp.web.currentUser.select('Id')(); - const cutoff = new Date(Date.now() - RECENT_MS).toISOString(); - // Фильтруем только по AuthorId, а «свежая или черновик» решаем в JS: статус - // в $filter не используем, потому что кириллица в сравнении там уже подводила - // (см. indexApprovalsForType). Берём последние OWN_REST_TOP заявок автора — - // это его собственные записи, их немного, и запрос ограничен по объёму. const rows = (await sp.web .getList(type.listId as string) .items.filter(`AuthorId eq ${me.Id}`) @@ -521,21 +610,28 @@ async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestTyp ); } - // Черновик берём ЛЮБОГО возраста: он живёт только у автора, правится часто и - // именно по нему индекс Search отстаёт заметнее всего, а увидеть свой - // черновик в «Моих заявках» пользователь ожидает сразу. - return rows - .filter((r) => { - const created = (r.Created as string) || ''; - return r.Status === REQUEST_STATUS.draft || created >= cutoff; - }) - .map((r) => toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own')); + 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); + console.error(`[Текущие заявки] «${type.name}»: не удалось прочитать свои заявки:`, (e as Error).message); return []; } } +// Свежие (созданные за последние RECENT_MS) заявки автора — довесок к Search, +// индекс мог их ещё не подхватить. +async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestType): Promise { + const cutoff = new Date(Date.now() - RECENT_MS).toISOString(); + + // Черновик берём ЛЮБОГО возраста: он живёт только у автора, правится часто и + // именно по нему индекс Search отстаёт заметнее всего, а увидеть свой + // черновик в «Моих заявках» пользователь ожидает сразу. + return (await getOwnRowsForType(context, type)).filter( + (r) => r.status === REQUEST_STATUS.draft || r.created >= cutoff + ); +} + // «Мои заявки» БЕЗ поиска — гибрид для масштаба: Search с серверной пагинацией // (RowLimit/StartRow) находит страницу без сканирования всего архива, REST по // «последним N минутам» добирает то, что Search мог не успеть проиндексировать @@ -544,18 +640,20 @@ async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestTyp export async function getOwnRequests( context: WebPartContext, types: IRequestType[], - page: number + page: number, + status: string = '' ): Promise { if (types.length === 0) return { rows: [], totalRows: 0 }; - const searchPage = await searchOwnRequests(context, types, '', page); + const searchPage = await searchOwnRequests(context, types, '', page, status); if (page !== 1) return searchPage; - const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t)))).reduce( - (all, r) => all.concat(r), - [] as IMyRequest[] - ); + // Довесок по свежести отбираем по статусу здесь: он приходит 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 searchKeys = new Set(searchPage.rows.map((r) => `${r.requestTypeId}-${r.id}`)); const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`)); @@ -575,7 +673,8 @@ export async function searchOwnRequests( context: WebPartContext, types: IRequestType[], query: string, - page: number + page: number, + status: string = '' ): Promise { if (types.length === 0) return { rows: [], totalRows: 0 }; @@ -586,6 +685,9 @@ 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 '); @@ -601,12 +703,25 @@ 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. const rows = entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r); - return { rows, totalRows: results.TotalRows || 0 }; + return { rows, totalRows }; } // «На согласовании»: список заявок уже отобран по строкам согласования diff --git a/src/webparts/requestPortal/shared/routing.test.ts b/src/webparts/requestPortal/shared/routing.test.ts index cc673d5..afecdb0 100644 --- a/src/webparts/requestPortal/shared/routing.test.ts +++ b/src/webparts/requestPortal/shared/routing.test.ts @@ -13,6 +13,8 @@ describe('parseRoute', () => { it('rpnav задаёт раздел бокового меню', () => { expect(parseRoute('?rpnav=approvals').nav).toBe('approvals'); + expect(parseRoute('?rpnav=execution').nav).toBe('execution'); + expect(parseRoute('?rpnav=rework').nav).toBe('rework'); expect(parseRoute('?rpnav=own').nav).toBe('own'); expect(parseRoute('?rpnav=search').nav).toBe('search'); }); @@ -73,6 +75,64 @@ describe('parseRoute', () => { }); }); +describe('подвкладки и фильтр «Моих заявок»', () => { + it('без rptab — подвкладка по умолчанию', () => { + expect(parseRoute('?rpnav=own').ownTab).toBe('created'); + }); + + it('rptab задаёт подвкладку', () => { + expect(parseRoute('?rpnav=own&rptab=approved').ownTab).toBe('approved'); + expect(parseRoute('?rpnav=own&rptab=rejected').ownTab).toBe('rejected'); + expect(parseRoute('?rpnav=own&rptab=executed').ownTab).toBe('executed'); + }); + + it('незнакомый rptab откатывается на подвкладку по умолчанию', () => { + expect(parseRoute('?rpnav=own&rptab=bogus').ownTab).toBe('created'); + }); + + it('вне «Моих заявок» подвкладка не читается — чужой rptab туда не протекает', () => { + expect(parseRoute('?rpnav=approvals&rptab=approved').ownTab).toBeUndefined(); + expect(parseRoute('?rpnav=search&rptab=approved').ownTab).toBeUndefined(); + }); + + it('фильтр по статусу действует только на вкладке «Инициированные»', () => { + expect(parseRoute('?rpnav=own&rpstatus=Черновик').ownStatus).toBe('Черновик'); + // У вкладок с решениями отбор задаёт сама вкладка — фильтр там бессмыслен. + expect(parseRoute('?rpnav=own&rptab=approved&rpstatus=Черновик').ownStatus).toBeUndefined(); + expect(parseRoute('?rpnav=approvals&rpstatus=Черновик').ownStatus).toBeUndefined(); + }); + + it('значения по умолчанию не засоряют адрес', () => { + expect(routeUrl({ mode: 'submit', nav: 'own', ownTab: 'created' })).not.toContain('rptab'); + expect(routeUrl({ mode: 'submit', nav: 'own' })).not.toContain('rptab'); + }); + + it('непустая подвкладка и фильтр попадают в адрес', () => { + expect(routeUrl({ mode: 'submit', nav: 'own', ownTab: 'rejected' })).toContain('rptab=rejected'); + expect(routeUrl({ mode: 'submit', nav: 'own', ownTab: 'created', ownStatus: 'Черновик' })).toContain('rpstatus='); + }); + + it('фильтр не пишется в адрес вместе с подвкладкой решений', () => { + const url = routeUrl({ mode: 'submit', nav: 'own', ownTab: 'approved', ownStatus: 'Черновик' }); + expect(url).toContain('rptab=approved'); + expect(url).not.toContain('rpstatus'); + }); + + it('уход из «Моих заявок» убирает подвкладку и фильтр из адреса', () => { + navigate({ mode: 'submit', nav: 'own', ownTab: 'rejected' }); + navigate({ mode: 'submit', nav: 'approvals' }); + expect(window.location.search).not.toContain('rptab'); + expect(window.location.search).not.toContain('rpstatus'); + }); + + it('round-trip для подвкладки решений', () => { + navigate({ mode: 'submit', nav: 'own', ownTab: 'executed' }); + const route = parseRoute(window.location.search); + expect(route.nav).toBe('own'); + expect(route.ownTab).toBe('executed'); + }); +}); + describe('routeUrl', () => { it('раздел по умолчанию не засоряет адрес параметром', () => { expect(routeUrl({ mode: 'submit', nav: 'new' })).toBe('/'); @@ -103,9 +163,24 @@ describe('navigate', () => { nav: 'own', formKey: 'hire', itemId: 42, + // Подвкладка «Моих заявок» всегда определена внутри раздела — в адресе её + // нет только потому, что это значение по умолчанию. + ownTab: 'created', + ownStatus: undefined, }); }); + it('подвкладка и фильтр «Моих заявок» переживают открытие и закрытие заявки', () => { + navigate({ mode: 'submit', nav: 'own', ownTab: 'created', ownStatus: 'Черновик' }); + navigate({ mode: 'approve', nav: 'own', formKey: 'hire', itemId: 42, ownTab: 'created', ownStatus: 'Черновик' }); + + const opened = parseRoute(window.location.search); + expect(opened.ownStatus).toBe('Черновик'); + + navigate({ mode: 'submit', nav: 'own', ownTab: opened.ownTab, ownStatus: opened.ownStatus }); + expect(parseRoute(window.location.search).ownStatus).toBe('Черновик'); + }); + it('round-trip для создания производной заявки', () => { navigate({ mode: 'create', nav: 'new', formKey: 'workplace', sourceId: 15 }); expect(parseRoute(window.location.search)).toEqual({ diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index e35fbfd..65f3dca 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -24,8 +24,25 @@ import * as React from 'react'; // отклонённую) на редактирование в той же выезжающей панели, что и просмотр. export type PortalMode = 'submit' | 'approve' | 'create' | 'edit'; -/** Раздел бокового меню. */ -export type NavSection = 'new' | 'approvals' | 'own' | 'search'; +/** + * Раздел бокового меню. + * + * Три раздела — очереди действий текущего пользователя, и они НЕ пересекаются + * (см. ApprovalQueueKind в myRequests.service.ts): + * 'approvals' — ждут его согласования (статус строки «На согласовании»), + * 'execution' — ждут его исполнения (статус строки «На исполнении»), + * 'rework' — ЕГО заявки, которые вернули ему на доработку (статус ЗАЯВКИ + * «Возвращена на доработку»); в отличие от первых двух смотрит + * на список данных по автору, а не на строки маршрута. + */ +export type NavSection = 'new' | 'approvals' | 'execution' | 'rework' | 'own' | 'search'; + +/** + * Подвкладка раздела «Мои заявки». 'created' — заявки, где пользователь автор + * (единственная, где есть фильтр по статусу); остальные — история его решений + * как участника чужих маршрутов. + */ +export type OwnTab = 'created' | 'approved' | 'rejected' | 'executed'; export interface IRoute { mode: PortalMode; @@ -37,15 +54,32 @@ export interface IRoute { itemId?: number; /** Id исходной заявки для предзаполнения — для 'create' (напр. орг. места на основе прием). */ sourceId?: number; + /** Подвкладка «Моих заявок»; задана только при nav='own'. */ + ownTab?: OwnTab; + /** Фильтр по статусу заявки; задан только при nav='own' и ownTab='created'. */ + ownStatus?: string; } const ROUTE_CHANGE_EVENT = 'rp-route-change'; function parseNav(raw: string | undefined): NavSection { - if (raw === 'approvals' || raw === 'own' || raw === 'search') return raw; + if ( + raw === 'approvals' || + raw === 'execution' || + raw === 'rework' || + raw === 'own' || + raw === 'search' + ) { + return raw; + } return 'new'; } +function parseOwnTab(raw: string | undefined): OwnTab { + if (raw === 'approved' || raw === 'rejected' || raw === 'executed') return raw; + return 'created'; +} + /** Разобрать строку запроса (по умолчанию — из адреса страницы) в маршрут. */ export function parseRoute(search: string = window.location.search): IRoute { const params = new URLSearchParams(search); @@ -57,19 +91,28 @@ export function parseRoute(search: string = window.location.search): IRoute { const sourceRaw = params.get('rpsource'); const sourceId = sourceRaw ? Number(sourceRaw) : undefined; + // Подвкладка и фильтр — состояние раздела «Мои заявки», поэтому вне его не + // читаются вовсе (иначе чужой rptab из ссылки «протёк» бы в другой раздел). + // Фильтр по статусу живёт только на вкладке «Инициированные»: у вкладок с + // решениями отбор задаёт сама вкладка. + const ownTab = nav === 'own' ? parseOwnTab(params.get('rptab') ?? undefined) : undefined; + const ownStatus = ownTab === 'created' ? params.get('rpstatus') ?? undefined : undefined; + // Открытая заявка (панель поверх раздела) — при полном наборе параметров. + // Раздел под панелью сохраняется целиком, вместе с подвкладкой и фильтром: + // закрыв панель, пользователь должен вернуться ровно в тот же список. if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) { - return { mode: 'approve', nav, formKey, itemId }; + return { mode: 'approve', nav, formKey, itemId, ownTab, ownStatus }; } // Редактирование собственной незавершённой заявки — та же панель, но с формой. if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) { - return { mode: 'edit', nav, formKey, itemId }; + return { mode: 'edit', nav, formKey, itemId, ownTab, ownStatus }; } // Создание производной заявки с предзаполнением из исходной. if (formKey && sourceId && !isNaN(sourceId)) { return { mode: 'create', nav: 'new', formKey, sourceId }; } - return { mode: 'submit', nav }; + return { mode: 'submit', nav, ownTab, ownStatus }; } // Собрать query-строку маршрута, сохранив посторонние параметры страницы. @@ -80,10 +123,19 @@ function buildQuery(route: IRoute): string { params.delete('rptype'); params.delete('rpid'); params.delete('rpsource'); + params.delete('rptab'); + params.delete('rpstatus'); if (route.nav !== 'new') { params.set('rpnav', route.nav); } + // Значения по умолчанию в адрес не пишем — чтобы обычный переход на «Мои + // заявки» давал чистую ссылку, а не ?rpnav=own&rptab=created. + if (route.nav === 'own') { + const ownTab = route.ownTab ?? 'created'; + if (ownTab !== 'created') params.set('rptab', ownTab); + else if (route.ownStatus) params.set('rpstatus', route.ownStatus); + } if ((route.mode === 'approve' || route.mode === 'edit') && route.formKey && route.itemId) { params.set('rpmode', route.mode); params.set('rptype', route.formKey); -- 2.34.1 From bba75b5ddd954909ce660888ad400e43eb5865ff Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 10 Aug 2026 14:07:51 +0500 Subject: [PATCH 02/13] =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../requestPortal/RequestPortalWebPart.ts | 2 + .../myRequests/RequestsTableView.module.scss | 13 +++- .../requestPortal/shared/diagnostics.test.ts | 64 +++++++++++++++++++ .../requestPortal/shared/diagnostics.ts | 51 +++++++++++++++ 4 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 src/webparts/requestPortal/shared/diagnostics.test.ts create mode 100644 src/webparts/requestPortal/shared/diagnostics.ts diff --git a/src/webparts/requestPortal/RequestPortalWebPart.ts b/src/webparts/requestPortal/RequestPortalWebPart.ts index c68f148..3ee3ac1 100644 --- a/src/webparts/requestPortal/RequestPortalWebPart.ts +++ b/src/webparts/requestPortal/RequestPortalWebPart.ts @@ -12,6 +12,7 @@ import * as strings from 'RequestPortalWebPartStrings'; import RequestPortal from './components/RequestPortal'; import { IRequestPortalProps } from './components/IRequestPortalProps'; import { initSP } from './shared/pnp/pnpjs'; +import { installErrorLogging } from './shared/diagnostics'; import { ErrorOverlay } from './shared/ui'; export interface IRequestPortalWebPartProps { @@ -41,6 +42,7 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart { + installErrorLogging(); // до всего остального — чтобы поймать и ранние отклонения initSP(this.context); // единый клиент PnPjs для записи в списки return this._getEnvironmentMessage().then(message => { this._environmentMessage = message; diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss b/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss index 4ea8fa0..2e564df 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss @@ -37,11 +37,20 @@ // Фильтр по статусу — только на вкладке «Инициированные». Стоит слева от поиска // и прижат к нему: вместе это один блок «чем сузить список». .statusFilter { + // appearance: none + своя SVG-галочка — как в shared/ui/Select. Нативная + // стрелка ОС рисуется своим шрифтом и рядом с остальными полями портала + // выглядит чужеродно (и по-разному в разных браузерах). + appearance: none; margin-left: auto; - padding: 9px 13px; + // Справа место под галочку; по вертикали — как у .search, чтобы оба контрола + // в шапке были одной высоты. + padding: 9px 34px 9px 13px; border: 1px solid $gray-300; border-radius: $r-sm; - background: $white; + background-color: $white; + background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%236B7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; font-family: $font-sans; font-size: $fs-sm; color: $gray-900; diff --git a/src/webparts/requestPortal/shared/diagnostics.test.ts b/src/webparts/requestPortal/shared/diagnostics.test.ts new file mode 100644 index 0000000..fbae34f --- /dev/null +++ b/src/webparts/requestPortal/shared/diagnostics.test.ts @@ -0,0 +1,64 @@ +import { installErrorLogging } from './diagnostics'; + +// jsdom не порождает unhandledrejection сам — событие диспатчим руками, как это +// сделал бы браузер. Конструктора PromiseRejectionEvent в jsdom нет, поэтому +// собираем обычное Event и дописываем reason (слушатель читает только его). +function emitRejection(reason: unknown): void { + const event = new Event('unhandledrejection') as Event & { reason?: unknown }; + event.reason = reason; + window.dispatchEvent(event); +} + +describe('installErrorLogging', () => { + let errors: unknown[][]; + let spy: jest.SpyInstance; + + beforeEach(() => { + errors = []; + spy = jest.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + errors.push(args); + }); + installErrorLogging(); + }); + + afterEach(() => spy.mockRestore()); + + it('печатает сообщение и стек обычной ошибки', () => { + emitRejection(new Error('Список недоступен')); + + const text = errors.map((a) => a.join(' ')).join('\n'); + expect(text).toContain('Список недоступен'); + expect(text).toContain('Стек'); + }); + + it('не теряет причину, если это не Error, а объект (типичный отказ PnPjs)', () => { + emitRejection({ status: 403, message: 'Access denied' }); + + const text = errors.map((a) => a.map((x) => JSON.stringify(x)).join(' ')).join('\n'); + expect(text).toContain('403'); + expect(text).toContain('Access denied'); + }); + + it('не падает на причине с циклической ссылкой', () => { + const cyclic: Record = { name: 'зацикленное' }; + cyclic.self = cyclic; + + expect(() => emitRejection(cyclic)).not.toThrow(); + expect(errors.length).toBeGreaterThan(0); + }); + + it('строка-причина попадает в лог как есть', () => { + emitRejection('просто текст'); + expect(errors.map((a) => a.join(' ')).join('\n')).toContain('просто текст'); + }); + + it('повторный вызов не вешает второй слушатель — ошибка не двоится', () => { + installErrorLogging(); + installErrorLogging(); + + emitRejection(new Error('однократно')); + + const count = errors.filter((a) => String(a[0]).indexOf('Необработанная ошибка') !== -1).length; + expect(count).toBe(1); + }); +}); diff --git a/src/webparts/requestPortal/shared/diagnostics.ts b/src/webparts/requestPortal/shared/diagnostics.ts new file mode 100644 index 0000000..6df3d74 --- /dev/null +++ b/src/webparts/requestPortal/shared/diagnostics.ts @@ -0,0 +1,51 @@ +// Читаемый лог необработанных отклонений промисов. +// +// Зачем: SPFx вешает на window свой обработчик unhandledrejection, и в консоль +// попадает только его собственный стек внутри минифицированного бандла — +// примерно такой: +// +// Error +// at vp (...request-portal-web-part_.js:9:397946) +// at t.onUnhandledRejection (...) +// +// Ни причины, ни места, где промис отклонился, там нет: SPFx логирует САМ факт, +// а не `reason`. Поэтому вешаем свой слушатель — он срабатывает наравне с +// SPFx-овским и печатает то, что действительно нужно: сообщение, стек и сам +// объект причины (у ошибок PnPjs/SharePoint внутри лежит ответ сервера). +// +// Ничего не подавляем (preventDefault не зовём) — поведение портала не +// меняется, добавляется только диагностика. + +const PREFIX = '[Портал заявок]'; + +// Слушатель вешается один раз на страницу: на странице может быть несколько +// экземпляров веб-части, и каждый вызвал бы install() в onInit — без флага +// одна и та же ошибка печаталась бы столько раз, сколько их размещено. +let installed = false; + +// Причиной отклонения может быть что угодно, не только Error — у PnPjs это +// бывает объект с полями ответа. Разбираем по факту, а не по ожиданию. +function describeReason(reason: unknown): string { + if (reason instanceof Error) return `${reason.name}: ${reason.message}`; + if (typeof reason === 'string') return reason; + try { + return JSON.stringify(reason); + } catch { + // Циклические ссылки — тогда хотя бы тип. + return Object.prototype.toString.call(reason); + } +} + +export function installErrorLogging(): void { + if (installed) return; + installed = true; + + window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => { + const reason: unknown = e.reason; + console.error(`${PREFIX} Необработанная ошибка: ${describeReason(reason)}`); + // Стек и объект — отдельными аргументами: так консоль браузера даёт + // раскрыть объект и перейти по стеку, а не показывает «[object Object]». + if (reason instanceof Error && reason.stack) console.error(`${PREFIX} Стек:`, reason.stack); + console.error(`${PREFIX} Причина целиком:`, reason); + }); +} -- 2.34.1 From 4cfb9a545dda60c4c7f76f3f64c14ee4b833012e Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 10 Aug 2026 15:18:00 +0500 Subject: [PATCH 03/13] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B8=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D0=BB=D0=B1=D1=86=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/myRequests/myRequests.service.ts | 52 ++++++++++--------- .../RecruitingHire/approval/provisioning.json | 2 +- .../forms/RecruitingHire/load.ts | 4 +- .../forms/RecruitingHire/provisioning.json | 2 +- .../forms/RecruitingHire/submit.ts | 2 +- .../WorkplaceSetup/approval/provisioning.json | 2 +- .../forms/WorkplaceSetup/load.ts | 4 +- .../forms/WorkplaceSetup/provisioning.json | 2 +- .../forms/WorkplaceSetup/submit.ts | 2 +- .../shared/approval/approvalList.service.ts | 16 ++++-- .../approval/approverDecision.service.ts | 2 +- .../shared/approval/models/statuses.ts | 15 ++++-- 12 files changed, 60 insertions(+), 45 deletions(-) diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 39f33e9..99b606c 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -38,18 +38,22 @@ const OWN_REST_TOP = 200; // ApproverOWSUSER в схеме поиска не находилось (разбирались 2026-08-06). // Суффикс — тип исходного поля: OWSUSER для Person/Group. const MP_APPROVER = 'TansuSiteApproverOWSUSER'; -// Статус строки маршрута — Choice-поле, отсюда суффикс OWSCHCS (подтверждено -// отладочным перебором 2026-08-06; у Person-полей он OWSUSER, см. MP_APPROVER). -const MP_STATUS = 'RequestStatusOWSCHCS'; +// Статус СТРОКИ МАРШРУТА (колонка ApprovalStatus списка «— согласование») — +// Choice-поле, отсюда суффикс OWSCHCS (подтверждено отладочным перебором +// 2026-08-06; у Person-полей он OWSUSER, см. MP_APPROVER). Колонку не путать с +// RequestStatus списка ДАННЫХ — это статус ЗАЯВКИ целиком, у него своя +// managed property (см. MP_TICKET_STATUS ниже). +const MP_STATUS = 'ApprovalStatusOWSCHCS'; -// То же самое для Choice-колонки «Статус» списка ДАННЫХ (внутреннее имя Status, -// см. forms/*/provisioning.json) — по ней отбираются «Доработка» и фильтр по -// статусу в «Моих заявках». Кириллица в значении проблемы не создаёт: KQL — не -// $filter (там она действительно подводит, см. indexApprovalsForType), и по -// MP_STATUS выше точно так же ищутся русские статусы строк маршрута. +// То же самое для Choice-колонки «Статус» списка ДАННЫХ (внутреннее имя +// RequestStatus, см. forms/*/provisioning.json) — по ней отбираются +// «Доработка» и фильтр по статусу в «Моих заявках». Кириллица в значении +// проблемы не создаёт: KQL — не $filter (там она действительно подводит, см. +// indexApprovalsForType), и по MP_STATUS выше точно так же ищутся русские +// статусы строк маршрута. // Если отбор по статусу вдруг начнёт стабильно возвращать 0 — проверять надо в // первую очередь ЭТО имя в схеме поиска (см. предупреждение в searchOwnRequests). -const MP_TICKET_STATUS = 'StatusOWSCHCS'; +const MP_TICKET_STATUS = 'RequestStatusOWSCHCS'; export function currentUserEmail(context: WebPartContext): string { return (context.pageContext.user.email || '').toLowerCase(); @@ -99,11 +103,11 @@ const MY_TURN_STATUSES: string[] = [REQUEST_STATUS.onApproval, REQUEST_STATUS.on // (иначе на клиенте нечем было посчитать «чья очередь»). Теперь очередь // проставляется при создании/решении (см. approvalList.service.ts/createSteps, // approverDecision.service.ts/decide) — «чья сейчас очередь» это просто -// значение колонки Status, поэтому здесь достаточно ОДНОГО отфильтрованного -// запроса: ApproverId (+ Status для 'myTurn'). Список согласования больше НЕ -// сканируется целиком, независимо от его размера. +// значение колонки ApprovalStatus, поэтому здесь достаточно ОДНОГО +// отфильтрованного запроса: ApproverId (+ ApprovalStatus для 'myTurn'). +// Список согласования больше НЕ сканируется целиком, независимо от его размера. // -// Заявку (Title/Status/Created) читаем сразу через expand по Lookup-полю +// Заявку (Title/RequestStatus/Created) читаем сразу через expand по Lookup-полю // Ticket — отдельный запрос к списку данных нужен только за Author // (двухуровневый expand Ticket/Author у SharePoint REST ненадёжен), и то — // ТОЛЬКО для строк текущей страницы (см. hydrateAuthors ниже), а не для всего @@ -227,7 +231,7 @@ export async function hydratePendingAuthors( } // Лёгкий индекс совпадающих заявок для одного типа: строки согласования по -// ApproverId → Title/Status/Created заявки отдельным запросом к списку ДАННЫХ +// ApproverId → Title/RequestStatus/Created заявки отдельным запросом к списку ДАННЫХ // (НЕ через expand по Lookup-полю Ticket — SharePoint REST не отдаёт так // произвольные поля связанного элемента, только через Person-поля вроде // Author; попытка `expand('Ticket').select('Ticket/Status', ...)` падает с 400). @@ -267,8 +271,8 @@ async function indexApprovalsForType( // SharePoint REST — резолвим email в числовой Id пользователя (как и везде в // проекте, см. ensureUser) и фильтруем по TansuSiteApproverId. // - // RequestStatus в $filter НЕ используем: подтверждено на практике — фильтр - // вида `RequestStatus eq 'На согласовании'` (кириллица в $filter) молча + // ApprovalStatus в $filter НЕ используем: подтверждено на практике — фильтр + // вида `ApprovalStatus eq 'На согласовании'` (кириллица в $filter) молча // возвращает 0 строк, даже когда без него те же строки находятся с этим же // значением статуса. Поэтому фильтруем только по TansuSiteApproverId // (числовое значение, проблем нет), а статус сравниваем в JS после @@ -289,7 +293,7 @@ async function indexApprovalsForType( const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString(); const recentRows = (await approvalList.items .filter(`TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'`) - .select('Id', 'TicketId', 'RequestStatus') + .select('Id', 'TicketId', 'ApprovalStatus') .top(2000)()) as Array>; // Статусы уходят в KQL: сервер сразу отдаёт только нужные строки, @@ -300,7 +304,7 @@ async function indexApprovalsForType( // REST-довесок берётся по Modified без учёта статуса (кириллица в $filter // ненадёжна, см. выше) — отбор по статусу делаем здесь, на клиенте. - stepRows = allRows.filter((r) => statuses.indexOf(r.RequestStatus as string) !== -1); + stepRows = allRows.filter((r) => statuses.indexOf(r.ApprovalStatus as string) !== -1); } else { // 'involved' — используется ТОЛЬКО для текстового поиска (см. // getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная @@ -328,7 +332,7 @@ async function indexApprovalsForType( stepRows.forEach((r) => { const ticketId = r.TicketId as number | undefined; if (ticketId && !stepStatusByTicket.has(ticketId)) { - stepStatusByTicket.set(ticketId, (r.RequestStatus as string) || ''); + stepStatusByTicket.set(ticketId, (r.ApprovalStatus as string) || ''); } }); if (ticketIds.length === 0) { @@ -341,7 +345,7 @@ async function indexApprovalsForType( const ticketFilter = ticketIds.map((id) => `Id eq ${id}`).join(' or '); const ticketRows = (await list.items .filter(ticketFilter) - .select('Id', 'Title', 'Status', 'Created') + .select('Id', 'Title', 'RequestStatus', 'Created') .top(ticketIds.length)()) as Array>; const rows = ticketRows.map((r) => ({ @@ -430,7 +434,7 @@ async function getInvolvedRowsViaSearch( return (await approvalList.items .filter(ids.map((id) => `Id eq ${id}`).join(' or ')) - .select('Id', 'TicketId', 'RequestStatus') + .select('Id', 'TicketId', 'ApprovalStatus') .top(ids.length)()) as Array>; } catch (e) { console.error( @@ -573,7 +577,7 @@ async function loadByIds( const rows = (await sp.web .getList(type.listId as string) .items.filter(ids.map((id) => `Id eq ${id}`).join(' or ')) - .select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title') + .select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title') .expand('Author') .top(ids.length)()) as Array>; rows.forEach((r) => { @@ -599,7 +603,7 @@ async function getOwnRowsForType(context: WebPartContext, type: IRequestType): P const rows = (await sp.web .getList(type.listId as string) .items.filter(`AuthorId eq ${me.Id}`) - .select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title') + .select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title') .expand('Author') .orderBy('Created', false) .top(OWN_REST_TOP)()) as Array>; @@ -861,7 +865,7 @@ function toMyRequest( return { id: r.Id as number, title: (r.Title as string) || '', - status: (r.Status as string) || '', + status: (r.RequestStatus as string) || '', authorEmail: author?.EMail || '', authorName: author?.Title || '', created: (r.Created as string) || '', diff --git a/src/webparts/requestPortal/forms/RecruitingHire/approval/provisioning.json b/src/webparts/requestPortal/forms/RecruitingHire/approval/provisioning.json index 45a8b4f..90b6141 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/approval/provisioning.json +++ b/src/webparts/requestPortal/forms/RecruitingHire/approval/provisioning.json @@ -2,7 +2,7 @@ "dataListTitle": "Прием на работу — согласование", "columns": [ { "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" }, - { "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] }, + { "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] }, { "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" }, { "internalName": "Date", "title": "Дата", "type": "DateTime" }, { "internalName": "Comment", "title": "Комментарий", "type": "Note" }, diff --git a/src/webparts/requestPortal/forms/RecruitingHire/load.ts b/src/webparts/requestPortal/forms/RecruitingHire/load.ts index b8d21fb..8a69434 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/load.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/load.ts @@ -21,7 +21,7 @@ export async function loadHireRequest(itemId: number): Promise { const item = (await sp.web.lists .getByTitle(LIST_TITLE) .items.getById(itemId) - .select(...SCALAR_FIELDS, 'Status')()) as Record; + .select(...SCALAR_FIELDS, 'RequestStatus')()) as Record; const data: IFormData = { ...EMPTY_DATA }; const writable = data as unknown as Record; @@ -37,5 +37,5 @@ export async function loadHireRequest(itemId: number): Promise { } }); - return { data, status: (item.Status as string) || '' }; + return { data, status: (item.RequestStatus as string) || '' }; } diff --git a/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json b/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json index ec43431..9032984 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json +++ b/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json @@ -2,7 +2,7 @@ "dataListTitle": "Прием на работу — данные", "columns": [ { "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" }, - { "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] }, + { "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] }, { "internalName": "Phone", "title": "Телефон", "type": "Text" }, { "internalName": "HireType", "title": "Тип приёма", "type": "Choice", "choices": ["Новый сотрудник", "Вместо уволенного"] }, diff --git a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts index 0014161..2a4b257 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts @@ -23,7 +23,7 @@ function buildTitle(data: IFormData): string { /** Поля элемента списка данных из модели формы (текст/выбор/даты/числа). */ function buildItem(data: IFormData, status: string): Record { - const item: Record = { Title: buildTitle(data), Status: status }; + const item: Record = { Title: buildTitle(data), RequestStatus: status }; (Object.keys(data) as (keyof IFormData)[]).forEach((key) => { const value = data[key]; diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json b/src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json index e9b0968..2ab403f 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json @@ -2,7 +2,7 @@ "dataListTitle": "Организация рабочего места — согласование", "columns": [ { "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" }, - { "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] }, + { "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] }, { "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Организация рабочего места — данные", "lookupShowField": "Title" }, { "internalName": "Date", "title": "Дата", "type": "DateTime" }, { "internalName": "Comment", "title": "Комментарий", "type": "Note" }, diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts b/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts index 0505621..0ac02aa 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts @@ -18,7 +18,7 @@ export async function loadWorkplaceRequest(itemId: number): Promise; + .select(...SCALAR_FIELDS, 'NeedsEquipment', 'RequestStatus')()) as Record; const data: IFormData = { ...EMPTY_DATA }; const writable = data as unknown as Record; @@ -36,5 +36,5 @@ export async function loadWorkplaceRequest(itemId: number): Promise { - const item: Record = { Title: buildTitle(data), Status: status }; + const item: Record = { Title: buildTitle(data), RequestStatus: status }; (Object.keys(data) as (keyof IFormData)[]).forEach((key) => { const value = data[key]; diff --git a/src/webparts/requestPortal/shared/approval/approvalList.service.ts b/src/webparts/requestPortal/shared/approval/approvalList.service.ts index 6e504a8..d77b7de 100644 --- a/src/webparts/requestPortal/shared/approval/approvalList.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalList.service.ts @@ -12,12 +12,18 @@ import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses'; // Внутренние имена колонок списка согласования (создаются провижинингом). const FIELDS = { - // Внутренние имена с префиксом группы колонок сайта (TansuSite) и «Request» — - // так они заведены в SharePoint. Из них SharePoint выводит имена managed - // properties для поиска: TansuSiteApproverOWSUSER и RequestStatusOWSCHCS - // (см. MP_APPROVER/MP_STATUS в features/myRequests/myRequests.service.ts). + // Внутренние имена с префиксом группы колонок сайта (TansuSite) там, где он + // есть, — так они заведены в SharePoint. Из них SharePoint выводит имена + // managed properties для поиска: TansuSiteApproverOWSUSER и + // ApprovalStatusOWSCHCS (см. MP_APPROVER/MP_STATUS в + // features/myRequests/myRequests.service.ts). + // + // ВАЖНО: колонка называется ApprovalStatus, а не Status и не RequestStatus — + // это статус СТРОКИ маршрута (список «— согласование»); статус ЗАЯВКИ в + // списке «— данных» лежит в отдельной колонке RequestStatus (см. + // models/statuses.ts — там оба набора значений сведены в одну картину). approver: 'TansuSiteApprover', // «Согласующий» (User) → TansuSiteApproverId - status: 'RequestStatus', // «Статус» (Choice) + status: 'ApprovalStatus', // «Статус» (Choice) строки маршрута ticket: 'Ticket', // «Заявка» (Lookup) → TicketId date: 'Date', // «Дата» (DateTime) comment: 'Comment', // «Комментарий» (Text) diff --git a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts index 40dcd4b..78d1c89 100644 --- a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts +++ b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts @@ -127,7 +127,7 @@ export class ApproverService { private async updateRequestStatus(id: number, status: string): Promise { await getSP().web.lists.getByTitle(this.dataListTitle).items.getById(id).update({ - Status: status, + RequestStatus: status, }); } } diff --git a/src/webparts/requestPortal/shared/approval/models/statuses.ts b/src/webparts/requestPortal/shared/approval/models/statuses.ts index 8aa551c..95c484b 100644 --- a/src/webparts/requestPortal/shared/approval/models/statuses.ts +++ b/src/webparts/requestPortal/shared/approval/models/statuses.ts @@ -45,16 +45,21 @@ export function isEditableStatus(status: string | undefined): boolean { // Наборов значений ДВА, и они не совпадают — это разные сущности: // -// 1) статус ЗАЯВКИ — колонка Status в списке «… — данные». Пишется формой -// (Черновик/На согласовании) и computeRequestStatus после каждого решения. -// «Ждёт очереди»/«На исполнении»/«Выполнено» здесь не бывают: это состояния -// отдельного шага маршрута, а не заявки целиком. +// 1) статус ЗАЯВКИ — колонка RequestStatus в списке «… — данные». Пишется +// формой (Черновик/На согласовании) и computeRequestStatus после каждого +// решения. «Ждёт очереди»/«На исполнении»/«Выполнено» здесь не бывают: это +// состояния отдельного шага маршрута, а не заявки целиком. // -// 2) статус СТРОКИ МАРШРУТА — колонка RequestStatus в списке «… — согласование». +// 2) статус СТРОКИ МАРШРУТА — колонка ApprovalStatus в списке «… — согласование». // Пишется createSteps/activateNextQueued/setDecision. «Выполнение»/«Закрыта» // здесь не бывают: это агрегатные состояния заявки, у отдельного шага // смысла не имеют. // +// Обе колонки переименованы (были Status в списке данных и RequestStatus в +// списке согласования соответственно) — переименование внутреннего имени в +// SharePoint не трогает уже сохранённые значения, отдельная миграция данных +// не нужна. +// // Держать их в синхроне с forms/*/provisioning.json и forms/*/approval/provisioning.json. export const REQUEST_STATUS_CHOICES: string[] = [ REQUEST_STATUS.draft, -- 2.34.1 From 71335b325e57337255b019c491ad3baf202daa30 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 10 Aug 2026 16:07:58 +0500 Subject: [PATCH 04/13] =?UTF-8?q?=D0=BA=D1=83=D1=87=D0=B0=20=D0=B4=D0=BE?= =?UTF-8?q?=D1=80=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 && ( )} @@ -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(' · ') || '—'} 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; // Открытая заявка (панель поверх раздела) — при полном наборе параметров. // Раздел под панелью сохраняется целиком, вместе с подвкладкой и фильтром: -- 2.34.1 From d2e16167c746adba1ec1dc61897f090af49651af Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 10 Aug 2026 16:49:23 +0500 Subject: [PATCH 05/13] =?UTF-8?q?=D0=BF=D0=BE=D1=84=D0=B8=D0=BA=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=BF=D0=B5=D1=80=D1=81=D0=B8=D1=81=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/RequestPortal.tsx | 18 +++++---- .../myRequests/RequestsTableView.module.scss | 11 ++++++ .../features/myRequests/RequestsTableView.tsx | 33 +++++++++++++++- .../requestPortal/shared/routing.test.ts | 38 +++++++++++++------ src/webparts/requestPortal/shared/routing.ts | 32 ++++++++++++---- 5 files changed, 103 insertions(+), 29 deletions(-) diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index f049407..4852ea9 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, persistOwnSelection, useRoute, NavSection, OwnTab } from '../shared/routing'; +import { navigate, persistOwnTab, persistOwnStatus, useRoute, NavSection, OwnTab } from '../shared/routing'; import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents'; import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges'; import { SideNav } from './SideNav/SideNav'; @@ -114,21 +114,23 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context // ИЗ другого раздела route.ownTab всё равно undefined (parseRoute заполняет // его только когда nav==='own'), а при возврате на уже открытую «own» их // подхватит storage-фолбэк в parseRoute — то же самое значение, которое - // пользователь выбирал последним (см. persistOwnSelection/gotoOwnTab). + // пользователь выбирал последним (см. persistOwnTab/gotoOwnTab). navigate({ mode: 'submit', nav }); }; const gotoOwnTab = (ownTab: OwnTab): void => { - // Фильтр по статусу относится только к «Инициированным» — на других - // подвкладках отбор задаёт сама вкладка, поэтому фильтр туда не тащим. - const ownStatus = ownTab === 'created' ? route.ownStatus : undefined; - persistOwnSelection(ownTab, ownStatus); - navigate({ mode: 'submit', nav: 'own', ownTab, ownStatus }); + // ownStatus здесь НЕ передаём (даже для 'created'!) — по той же причине, + // что и в goto() выше: он подхватится storage-фолбэком в parseRoute. Если + // передать route.ownStatus явно, при переходе с ДРУГОЙ подвкладки он всегда + // undefined (сейчас мы не на 'created'), и фильтр на «Инициированных» + // выглядел бы каждый раз сброшенным — ровно тот баг, который тут был. + persistOwnTab(ownTab); + navigate({ mode: 'submit', nav: 'own', ownTab }); }; const setOwnStatus = (ownStatus: string): void => { const normalized = ownStatus || undefined; - persistOwnSelection('created', normalized); + persistOwnStatus(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 fd77c0e..07b4978 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss @@ -66,6 +66,17 @@ margin-left: 0; } +// Цвет пунктов списка — те же цвета, что у StatusBadge (см. statusOptionClassName +// в RequestsTableView.tsx). Только color: background-color нативный
- {r.status || '—'} + {r.status ? : '—'}
- {r.status || '—'} + {r.status ? : '—'} {r.kind === 'own' ? 'Создал(а) я' : 'Участвую'}
- + )} diff --git a/src/webparts/requestPortal/features/myRequests/SearchView.tsx b/src/webparts/requestPortal/features/myRequests/SearchView.tsx index e6e3cbf..38ae872 100644 --- a/src/webparts/requestPortal/features/myRequests/SearchView.tsx +++ b/src/webparts/requestPortal/features/myRequests/SearchView.tsx @@ -20,6 +20,12 @@ const AUTO_REFRESH_MS = 10 * 60 * 1000; export interface ISearchViewProps { context: WebPartContext; + /** + * Текущая страница (1-based) и её смена — как и в RequestsTableView, страница + * живёт в маршруте, а не внутри компонента (см. shared/routing.ts). + */ + page: number; + onPageChange: (page: number) => void; /** Клик по строке — открыть заявку (в выезжающей панели). */ onOpen: (request: IMyRequest) => void; } @@ -94,11 +100,10 @@ const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => voi // после решения согласующего) — но т.к. это по большей части Search, свежая // правка может проявиться не мгновенно (частично компенсируется довеском // последних 10 минут через REST — см. myRequests.service.ts). -export const SearchView: React.FC = ({ context, onOpen }) => { +export const SearchView: React.FC = ({ context, page, onPageChange, onOpen }) => { const [types, setTypes] = React.useState(undefined); const [rows, setRows] = React.useState(undefined); const [totalRows, setTotalRows] = React.useState(0); - const [page, setPage] = React.useState(1); const [query, setQuery] = usePersistedQuery('search'); // query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что // уходит в запрос (после паузы в наборе). Здесь это особенно важно: поиск @@ -107,7 +112,15 @@ export const SearchView: React.FC = ({ context, onOpen }) => { const debouncedQuery = useDebouncedQuery(query); const [error, setError] = React.useState(''); - React.useEffect(() => setPage(1), [debouncedQuery]); + // Новый запрос — новый набор результатов, страница от прошлого к нему не + // относится. Сравниваем с предыдущим значением, чтобы не сбросить страницу, + // пришедшую из адреса, на первом же рендере (см. RequestsTableView). + const prevQuery = React.useRef(debouncedQuery); + React.useEffect(() => { + if (prevQuery.current === debouncedQuery) return; + prevQuery.current = debouncedQuery; + if (page !== 1) onPageChange(1); + }, [debouncedQuery, page, onPageChange]); React.useEffect(() => { let cancelled = false; @@ -198,7 +211,7 @@ export const SearchView: React.FC = ({ context, onOpen }) => { {!error && hasQuery && rows && totalRows > 0 && ( <>
- + )} diff --git a/src/webparts/requestPortal/shared/routing.test.ts b/src/webparts/requestPortal/shared/routing.test.ts index 6f40557..70e1618 100644 --- a/src/webparts/requestPortal/shared/routing.test.ts +++ b/src/webparts/requestPortal/shared/routing.test.ts @@ -1,4 +1,12 @@ -import { IRoute, navigate, parseRoute, persistOwnStatus, persistOwnTab, routeUrl } from './routing'; +import { + IRoute, + navigate, + parseRoute, + persistOwnStatus, + persistOwnTab, + persistPage, + routeUrl, +} from './routing'; // buildQuery/navigate читают и пишут window.location через history API, а // подвкладка/фильтр «Моих заявок» — ещё и localStorage (см. persistOwnTab/ @@ -11,7 +19,8 @@ beforeEach(() => { describe('parseRoute', () => { it('пустой адрес — подача заявки в разделе «Новая заявка»', () => { - expect(parseRoute('')).toEqual({ mode: 'submit', nav: 'new' }); + // page всегда определён (1 — значение по умолчанию), в адрес не пишется. + expect(parseRoute('')).toEqual({ mode: 'submit', nav: 'new', page: 1 }); }); it('rpnav задаёт раздел бокового меню', () => { @@ -32,6 +41,7 @@ describe('parseRoute', () => { nav: 'new', formKey: 'hire', itemId: 42, + page: 1, }); }); @@ -45,6 +55,7 @@ describe('parseRoute', () => { nav: 'new', formKey: 'hire', itemId: 7, + page: 1, }); }); @@ -129,6 +140,87 @@ describe('подвкладки и фильтр «Моих заявок»', () => }); }); +describe('страница таблицы (rppage + persistPage)', () => { + it('без rppage — первая страница', () => { + expect(parseRoute('?rpnav=own').page).toBe(1); + }); + + it('rppage задаёт страницу', () => { + expect(parseRoute('?rpnav=own&rppage=3').page).toBe(3); + }); + + it('мусор в rppage трактуется как первая страница, а не роняет разбор', () => { + expect(parseRoute('?rpnav=own&rppage=0').page).toBe(1); + expect(parseRoute('?rpnav=own&rppage=-2').page).toBe(1); + expect(parseRoute('?rpnav=own&rppage=abc').page).toBe(1); + expect(parseRoute('?rpnav=own&rppage=1.5').page).toBe(1); + }); + + it('первая страница не засоряет адрес, непервая — попадает', () => { + expect(routeUrl({ mode: 'submit', nav: 'own', page: 1 })).not.toContain('rppage'); + expect(routeUrl({ mode: 'submit', nav: 'own' })).not.toContain('rppage'); + expect(routeUrl({ mode: 'submit', nav: 'own', page: 4 })).toContain('rppage=4'); + }); + + it('round-trip страницы через navigate', () => { + navigate({ mode: 'submit', nav: 'approvals', page: 2 }); + expect(parseRoute(window.location.search).page).toBe(2); + }); + + it('без rppage подставляется запомненная для ЭТОЙ таблицы страница', () => { + persistPage({ mode: 'submit', nav: 'approvals' }, 5); + expect(parseRoute('?rpnav=approvals').page).toBe(5); + }); + + it('страница запоминается отдельно по таблицам и не переносится между разделами', () => { + persistPage({ mode: 'submit', nav: 'approvals' }, 5); + // В другом разделе своя нумерация — чужая страница туда попасть не должна, + // иначе можно приземлиться на несуществующую (у него всего одна страница). + expect(parseRoute('?rpnav=execution').page).toBe(1); + }); + + it('подвкладки «Моих заявок» — тоже разные таблицы, у каждой своя страница', () => { + persistPage({ mode: 'submit', nav: 'own', ownTab: 'created' }, 3); + persistPage({ mode: 'submit', nav: 'own', ownTab: 'approved' }, 7); + + expect(parseRoute('?rpnav=own&rptab=created').page).toBe(3); + expect(parseRoute('?rpnav=own&rptab=approved').page).toBe(7); + }); + + it('явная страница в адресе имеет приоритет над запомненной', () => { + persistPage({ mode: 'submit', nav: 'approvals' }, 5); + expect(parseRoute('?rpnav=approvals&rppage=2').page).toBe(2); + }); + + it('возврат на первую страницу стирает запомненную', () => { + persistPage({ mode: 'submit', nav: 'approvals' }, 5); + expect(parseRoute('?rpnav=approvals').page).toBe(5); + + persistPage({ mode: 'submit', nav: 'approvals' }, 1); + expect(parseRoute('?rpnav=approvals').page).toBe(1); + }); + + it('страница переживает открытие заявки поверх списка', () => { + navigate({ mode: 'submit', nav: 'own', ownTab: 'created', page: 3 }); + navigate({ mode: 'approve', nav: 'own', ownTab: 'created', formKey: 'hire', itemId: 42, page: 3 }); + expect(parseRoute(window.location.search).page).toBe(3); + }); + + it('localStorage недоступен — страница просто первая, без падения', () => { + 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').page).toBe(1); + } finally { + window.localStorage.getItem = original; + } + }); +}); + describe('persistOwnTab/persistOwnStatus — запоминание подвкладки/фильтра между визитами', () => { it('без явного rptab в адресе подставляется запомненная подвкладка', () => { persistOwnTab('rejected'); @@ -230,6 +322,7 @@ describe('navigate', () => { // нет только потому, что это значение по умолчанию. ownTab: 'created', ownStatus: undefined, + page: 1, }); }); diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index 4055eb0..012b841 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -58,6 +58,8 @@ export interface IRoute { ownTab?: OwnTab; /** Фильтр по статусу заявки; задан только при nav='own' и ownTab='created'. */ ownStatus?: string; + /** Номер страницы таблицы (1-based). 1 — значение по умолчанию, в адрес не пишется. */ + page?: number; } const ROUTE_CHANGE_EVENT = 'rp-route-change'; @@ -88,6 +90,50 @@ const ROUTE_CHANGE_EVENT = 'rp-route-change'; // вернувшись обратно, фильтр каждый раз сбрасывался на «Все статусы». const OWN_TAB_STORAGE_KEY = 'rp-own-tab'; const OWN_STATUS_STORAGE_KEY = 'rp-own-status'; +// Страница запоминается ОТДЕЛЬНО ДЛЯ КАЖДОЙ таблицы (см. viewKeyOf): один общий +// номер переносился бы между разделами, где у каждого свой объём данных, — +// уйдя со страницы 3 «Моих заявок» в «На согласовании», где страница всего +// одна, пользователь попал бы в пустоту. +const PAGE_STORAGE_PREFIX = 'rp-page:'; + +/** + * Ключ таблицы для запоминания страницы. Совпадает по смыслу с ListView в + * RequestsTableView (плюс 'search'): у каждой подвкладки «Моих заявок» свой + * список и, значит, своя страница. + */ +function viewKeyOf(nav: NavSection, ownTab: OwnTab | undefined): string { + return nav === 'own' ? `own:${ownTab ?? 'created'}` : nav; +} + +function readStoredPage(viewKey: string): number | undefined { + try { + const raw = window.localStorage.getItem(PAGE_STORAGE_PREFIX + viewKey); + if (!raw) return undefined; + const page = Number(raw); + return Number.isInteger(page) && page > 1 ? page : undefined; + } catch { + return undefined; + } +} + +/** + * Запомнить страницу текущей таблицы. Ключ выводится из самого маршрута — + * вызывающей стороне не нужно знать, как таблицы различаются между собой. + */ +export function persistPage(route: IRoute, page: number): void { + const viewKey = viewKeyOf(route.nav, route.ownTab); + try { + // Первая страница — значение по умолчанию: храним её ОТСУТСТВИЕМ ключа, + // иначе в localStorage копился бы мусор из единиц по каждой таблице. + if (page > 1) { + window.localStorage.setItem(PAGE_STORAGE_PREFIX + viewKey, String(page)); + } else { + window.localStorage.removeItem(PAGE_STORAGE_PREFIX + viewKey); + } + } catch { + // localStorage недоступен (приватный режим и т.п.) — просто не сохраняем. + } +} function readStoredOwnTab(): OwnTab { try { @@ -164,21 +210,29 @@ 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') ?? readStoredOwnStatus() : undefined; + // Страница: из адреса, иначе — запомненная для ЭТОЙ таблицы. Мусор в + // параметре (0, минус, буквы) трактуем как первую страницу, а не как ошибку: + // ссылка приходит извне, ронять из-за неё раздел незачем. + const pageRaw = Number(params.get('rppage')); + const page = + Number.isInteger(pageRaw) && pageRaw > 1 ? pageRaw : readStoredPage(viewKeyOf(nav, ownTab)) ?? 1; + // Открытая заявка (панель поверх раздела) — при полном наборе параметров. - // Раздел под панелью сохраняется целиком, вместе с подвкладкой и фильтром: - // закрыв панель, пользователь должен вернуться ровно в тот же список. + // Раздел под панелью сохраняется целиком, вместе с подвкладкой, фильтром и + // страницей: закрыв панель, пользователь должен вернуться ровно в тот же + // список и на то же место в нём. if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) { - return { mode: 'approve', nav, formKey, itemId, ownTab, ownStatus }; + return { mode: 'approve', nav, formKey, itemId, ownTab, ownStatus, page }; } // Редактирование собственной незавершённой заявки — та же панель, но с формой. if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) { - return { mode: 'edit', nav, formKey, itemId, ownTab, ownStatus }; + return { mode: 'edit', nav, formKey, itemId, ownTab, ownStatus, page }; } // Создание производной заявки с предзаполнением из исходной. if (formKey && sourceId && !isNaN(sourceId)) { return { mode: 'create', nav: 'new', formKey, sourceId }; } - return { mode: 'submit', nav, ownTab, ownStatus }; + return { mode: 'submit', nav, ownTab, ownStatus, page }; } // Собрать query-строку маршрута, сохранив посторонние параметры страницы. @@ -191,6 +245,7 @@ function buildQuery(route: IRoute): string { params.delete('rpsource'); params.delete('rptab'); params.delete('rpstatus'); + params.delete('rppage'); if (route.nav !== 'new') { params.set('rpnav', route.nav); @@ -202,6 +257,8 @@ function buildQuery(route: IRoute): string { if (ownTab !== 'created') params.set('rptab', ownTab); else if (route.ownStatus) params.set('rpstatus', route.ownStatus); } + // Первая страница — тоже значение по умолчанию (см. выше про чистую ссылку). + if (route.page && route.page > 1) params.set('rppage', String(route.page)); if ((route.mode === 'approve' || route.mode === 'edit') && route.formKey && route.itemId) { params.set('rpmode', route.mode); params.set('rptype', route.formKey); -- 2.34.1 From b4b40ce2354a15e405d7a8ecc8d0d463398763f2 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 10 Aug 2026 17:17:40 +0500 Subject: [PATCH 08/13] =?UTF-8?q?=D0=B2=D1=81=D0=B5=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B1=D0=BB=D0=B5=D0=BC=D1=8B=20=D1=81=20=D0=BA=D0=BD=D0=BE?= =?UTF-8?q?=D0=BF=D0=BE=D1=87=D0=BA=D0=B0=D0=BC=D0=B8=20=D0=B2=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=20=D1=83=D1=88=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shared/approval/ui/useFlipRows.ts | 48 +++++++++++++++---- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/src/webparts/requestPortal/shared/approval/ui/useFlipRows.ts b/src/webparts/requestPortal/shared/approval/ui/useFlipRows.ts index 869c12e..9fc8145 100644 --- a/src/webparts/requestPortal/shared/approval/ui/useFlipRows.ts +++ b/src/webparts/requestPortal/shared/approval/ui/useFlipRows.ts @@ -67,6 +67,39 @@ function drop(row: HTMLTableRowElement): void { }); } +/** + * Положение строк ОТНОСИТЕЛЬНО их , а не окна. + * + * Это принципиально: getBoundingClientRect() отдаёт координату относительно + * ОКНА, а прокрутка страницы не вызывает ре-рендер — значит, запомненные с + * прошлого рендера позиции остаются от прежнего положения прокрутки. Стоит + * пользователю прокрутить страницу к маршруту и нажать стрелку, как в сдвиг + * попадает вся дельта прокрутки (сотни пикселей), и строки улетают за пределы + * таблицы. Именно так выглядел баг «первое нажатие ломает, дальше нормально»: + * повторные клики уже сравнивались с позициями, замеренными при текущей + * прокрутке, поэтому шли правильно. + * + * Относительная база от этого избавляет: и строка, и замеряются в + * одном кадре, поэтому прокрутка сокращается. Заодно это чинит и случай, когда + * блок маршрута целиком уезжает из-за изменений НАД ним (например, свернулся + * блок вложений): tbody уезжает вместе со строками, и мнимого сдвига нет. + */ +function measureTops(nodes: Map): Map { + const tops = new Map(); + + let firstRow: HTMLTableRowElement | undefined; + nodes.forEach((el) => { + if (!firstRow) firstRow = el; + }); + if (!firstRow) return tops; + + const container = (firstRow as HTMLTableRowElement).parentElement; + const base = container ? container.getBoundingClientRect().top : 0; + + nodes.forEach((el, key) => tops.set(key, el.getBoundingClientRect().top - base)); + return tops; +} + /** * Возвращает функцию, которая по ключу строки отдаёт ref-колбэк для её . * @param activeKeys ключи строк того шага, который двигает пользователь — @@ -126,31 +159,28 @@ export function useFlipRows( if (!orderChanged) { // Порядок тот же — просто обновляем базовую геометрию для следующего // ДЕЙСТВИТЕЛЬНОГО сравнения, без единой анимации на этом проходе. - const current = new Map(); - nodes.current.forEach((el, key) => current.set(key, el.getBoundingClientRect().top)); - prevTops.current = current; + prevTops.current = measureTops(nodes.current); return; } // Сначала запоминаем, где строка ВИДНА сейчас: при быстрых повторных кликах // она в этот момент ещё летит, и новая анимация обязана стартовать от её // текущего положения на экране, иначе будет рывок. - const visualTops = new Map(); - nodes.current.forEach((el, key) => visualTops.set(key, el.getBoundingClientRect().top)); + const visualTops = measureTops(nodes.current); running.current.forEach((a) => a.cancel()); running.current = []; nodes.current.forEach(drop); const previous = prevTops.current; - const current = new Map(); + // После отмены анимаций transform снят — это истинные новые положения. + const current = measureTops(nodes.current); const moved: Array<{ el: HTMLTableRowElement; key: string; shift: number }> = []; const entered: HTMLTableRowElement[] = []; nodes.current.forEach((el, key) => { - // После отмены анимаций transform снят — это истинное новое положение. - const layoutTop = el.getBoundingClientRect().top; - current.set(key, layoutTop); + const layoutTop = current.get(key); + if (layoutTop === undefined) return; const before = previous.get(key); if (before === undefined) { -- 2.34.1 From 3dd4195539349640ef9a1717e901a53540f083d5 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 10 Aug 2026 17:36:55 +0500 Subject: [PATCH 09/13] =?UTF-8?q?=D0=B2=D1=81=D1=8F=D0=BA=D0=B8=D0=B5=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D1=8B=20=D0=BB=D0=B8=D1=88?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=83=D0=B1=D1=80=D0=B0=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/RequestPortal.tsx | 45 +++++++++---- .../features/myRequests/RequestsTableView.tsx | 49 +++++++++++--- .../requestPortal/shared/routing.test.ts | 65 +++++++++++++++++++ src/webparts/requestPortal/shared/routing.ts | 29 +++++++++ 4 files changed, 167 insertions(+), 21 deletions(-) diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index e27dce2..a01f513 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -7,6 +7,9 @@ import { persistOwnTab, persistOwnStatus, persistPage, + storedOwnStatus, + storedOwnTab, + storedPageFor, useRoute, NavSection, OwnTab, @@ -118,22 +121,40 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context // таблица на него подписана и перечитает данные фоном, не убирая с экрана // текущие строки (см. RequestsTableView/SearchView). if (nav === route.nav && route.mode === 'submit') emitRequestsChanged(); - // Подвкладку/фильтр «Моих заявок» здесь передавать не нужно: при переходе - // ИЗ другого раздела route.ownTab всё равно undefined (parseRoute заполняет - // его только когда nav==='own'), а при возврате на уже открытую «own» их - // подхватит storage-фолбэк в parseRoute — то же самое значение, которое - // пользователь выбирал последним (см. persistOwnTab/gotoOwnTab). - navigate({ mode: 'submit', nav }); + // Всё восстанавливаемое состояние раздела передаём ЯВНО, из запомненного. + // parseRoute подставил бы его и сам, но тогда оно осталось бы только в + // состоянии и не попало бы в адрес: ссылка на раздел вела бы на первую + // страницу без фильтра, а на экране была бы открыта другая. + // + // Брать route.ownTab/route.ownStatus здесь нельзя: при переходе ИЗ другого + // раздела они undefined (parseRoute заполняет их только при nav==='own'), + // и явная передача сбросила бы выбор пользователя. + if (nav === 'own') { + const ownTab = storedOwnTab(); + navigate({ + mode: 'submit', + nav, + ownTab, + ownStatus: storedOwnStatus(ownTab), + page: storedPageFor('own', ownTab), + }); + return; + } + navigate({ mode: 'submit', nav, page: storedPageFor(nav) }); }; const gotoOwnTab = (ownTab: OwnTab): void => { - // ownStatus здесь НЕ передаём (даже для 'created'!) — по той же причине, - // что и в goto() выше: он подхватится storage-фолбэком в parseRoute. Если - // передать route.ownStatus явно, при переходе с ДРУГОЙ подвкладки он всегда - // undefined (сейчас мы не на 'created'), и фильтр на «Инициированных» - // выглядел бы каждый раз сброшенным — ровно тот баг, который тут был. + // Фильтр и страница — из запомненного для ЭТОЙ подвкладки (см. goto выше о + // том, почему не из route). На вкладках с решениями фильтра нет по смыслу — + // storedOwnStatus сам вернёт undefined. persistOwnTab(ownTab); - navigate({ mode: 'submit', nav: 'own', ownTab }); + navigate({ + mode: 'submit', + nav: 'own', + ownTab, + ownStatus: storedOwnStatus(ownTab), + page: storedPageFor('own', ownTab), + }); }; const setOwnStatus = (ownStatus: string): void => { diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index a5fa6e3..37c67e5 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -106,9 +106,19 @@ function statusOptionClassName(status: string): string { // Колонки: «Автор» нужен там, где заявка чужая; «Статус» — где он не следует из // самого раздела. В истории решений важно и то, и другое: заявка чужая, а её // текущий статус мог уйти дальше вашего решения. -function columnsFor(view: ListView): { author: boolean; status: boolean } { +// +// 'rework' — свои заявки (автор не нужен) И все с одним статусом «Возвращена на +// доработку», раз именно по нему раздел и отобран (см. getOwnRequestsByStatus): +// колонка из одинаковых плашек ничего не сообщает, только шумит. Та же логика, +// что у очередей согласования/исполнения. +// +// 'own:created' — статус нужен, только пока показаны ВСЕ заявки. Стоит выбрать +// его в фильтре, и колонка вырождается в тот же столбец одинаковых плашек, +// поэтому она зависит не от одного вида, а ещё и от фильтра. +function columnsFor(view: ListView, statusFilter: string): { author: boolean; status: boolean } { if (view === 'approvals' || view === 'execution') return { author: true, status: false }; - if (view === 'rework' || view === 'own:created') return { author: false, status: true }; + if (view === 'rework') return { author: false, status: false }; + if (view === 'own:created') return { author: false, status: !statusFilter }; return { author: true, status: true }; } @@ -142,15 +152,14 @@ function formatDate(iso: string): string { // строка, а не только название), поэтому строка — с onClick + tabIndex // (доступ с клавиатуры: Enter/Space). // -// «approvals»: автор — не текущий пользователь, поэтому нужна колонка «Автор»; -// статус не показываем — здесь все заявки по определению на согласовании. -// «own»: автор всегда текущий пользователь, зато важен статус. +// Набор колонок зависит от раздела и от фильтра — см. columnsFor. const Table: React.FC<{ rows: IMyRequest[]; view: ListView; + statusFilter: string; onOpen: (request: IMyRequest) => void; -}> = ({ rows, view, onOpen }) => { - const columns = columnsFor(view); +}> = ({ rows, view, statusFilter, onOpen }) => { + const columns = columnsFor(view, statusFilter); return (
@@ -247,11 +256,22 @@ export const RequestsTableView: React.FC = ({ const [totalRows, setTotalRows] = React.useState(0); const [query, setQuery] = usePersistedQuery(view); const [error, setError] = React.useState(''); + // query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что // уходит в запрос (после паузы в наборе). Всё ниже завязано на debouncedQuery, // иначе поиск бил бы запросом по каждой набранной букве. const debouncedQuery = useDebouncedQuery(query); + // Какой ИМЕННО таблице принадлежат лежащие в состоянии rows/totalRows. + // Компонент один на все разделы (см. LIST_VIEWS в RequestPortal), поэтому при + // переключении раздела его состояние ещё хранит данные ПРЕДЫДУЩЕГО, пока не + // отработает загрузка. Без этой отметки сброс страницы ниже принимал решение + // по чужому totalRows и сбивал страницу на первую — из-за чего запомненная + // страница затиралась при каждом переходе по левому меню. + // Страница в ключ НЕ входит: totalRows один на всю таблицу, а не на страницу. + const dataKey = `${view}|${statusFilter}|${debouncedQuery.trim()}`; + const [loadedKey, setLoadedKey] = React.useState(''); + // Смена ЗАПРОСА сбрасывает страницу: набор результатов другой, и «страница 3» // от прошлого запроса к нему отношения не имеет. Смену вкладки/фильтра здесь // НЕ обрабатываем — там страница приходит уже правильной из маршрута @@ -292,6 +312,9 @@ export const RequestsTableView: React.FC = ({ const cached = readPage(cacheKey); setRows(cached ? cached.rows : undefined); setTotalRows(cached ? cached.totalRows : 0); + // Пока не подтвердим, что загруженное относится к ТЕКУЩЕЙ таблице, ничего + // на основании totalRows решать нельзя — см. loadedKey ниже. + setLoadedKey(cached ? dataKey : ''); const q = debouncedQuery.trim(); @@ -340,6 +363,9 @@ export const RequestsTableView: React.FC = ({ setRows(fresh.rows); setTotalRows(fresh.totalRows); } + // Данные точно от текущей таблицы — теперь по ним можно судить о + // количестве страниц (см. сброс страницы ниже). + setLoadedKey(dataKey); setError(''); } catch (e) { if (!cancelled) setError((e as Error).message); @@ -372,9 +398,14 @@ export const RequestsTableView: React.FC = ({ // из запомненной, а заявок с тех пор стало меньше (часть согласовали, и они // ушли из этой очереди). Показывать пустую таблицу с непустым счётчиком — // выглядит как поломка, поэтому возвращаемся на последнюю существующую. + // + // Строго ПОСЛЕ проверки loadedKey: totalRows в состоянии может быть ещё от + // предыдущей таблицы (компонент общий), и решение по нему сбило бы страницу + // на первую при обычном переходе по левому меню. React.useEffect(() => { + if (loadedKey !== dataKey) return; if (rows && page > totalPages) onPageChange(totalPages); - }, [rows, page, totalPages, onPageChange]); + }, [loadedKey, dataKey, rows, page, totalPages, onPageChange]); return (
@@ -419,7 +450,7 @@ export const RequestsTableView: React.FC = ({ )} {!error && rows && total > 0 && ( <> -
+
)} diff --git a/src/webparts/requestPortal/shared/routing.test.ts b/src/webparts/requestPortal/shared/routing.test.ts index 70e1618..924c292 100644 --- a/src/webparts/requestPortal/shared/routing.test.ts +++ b/src/webparts/requestPortal/shared/routing.test.ts @@ -6,6 +6,9 @@ import { persistOwnTab, persistPage, routeUrl, + storedOwnStatus, + storedOwnTab, + storedPageFor, } from './routing'; // buildQuery/navigate читают и пишут window.location через history API, а @@ -206,6 +209,34 @@ describe('страница таблицы (rppage + persistPage)', () => { expect(parseRoute(window.location.search).page).toBe(3); }); + // Ради этого storedPageFor и заведён: при переходе по меню маршрут СТРОИТСЯ, + // и если не подставить запомненную страницу явно, она останется только в + // состоянии — в адресе будет первая, а на экране открытая (см. goto в + // RequestPortal.tsx). + it('storedPageFor отдаёт запомненную страницу нужной таблицы', () => { + persistPage({ mode: 'submit', nav: 'approvals' }, 4); + expect(storedPageFor('approvals')).toBe(4); + expect(storedPageFor('execution')).toBe(1); + }); + + it('storedPageFor различает подвкладки «Моих заявок»', () => { + persistPage({ mode: 'submit', nav: 'own', ownTab: 'rejected' }, 6); + expect(storedPageFor('own', 'rejected')).toBe(6); + expect(storedPageFor('own', 'created')).toBe(1); + }); + + it('storedPageFor без подвкладки берёт запомненную — ту, что и откроется', () => { + persistOwnTab('executed'); + persistPage({ mode: 'submit', nav: 'own', ownTab: 'executed' }, 2); + expect(storedPageFor('own')).toBe(2); + }); + + it('переход по меню кладёт запомненную страницу в адрес', () => { + persistPage({ mode: 'submit', nav: 'approvals' }, 3); + navigate({ mode: 'submit', nav: 'approvals', page: storedPageFor('approvals') }); + expect(window.location.search).toContain('rppage=3'); + }); + it('localStorage недоступен — страница просто первая, без падения', () => { const original = window.localStorage.getItem; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- имитация приватного режима, где чтение localStorage бросает @@ -253,6 +284,40 @@ describe('persistOwnTab/persistOwnStatus — запоминание подвкл expect(parseRoute('?rpnav=approvals').ownTab).toBeUndefined(); }); + // Как и со страницей: при переходе маршрут СТРОИТСЯ, и без явной подстановки + // восстановленный фильтр остался бы только в состоянии — в адресе его бы не + // было (см. goto/gotoOwnTab в RequestPortal.tsx). + it('storedOwnStatus отдаёт фильтр только для вкладки «Инициированные»', () => { + persistOwnStatus('Отклонено'); + expect(storedOwnStatus('created')).toBe('Отклонено'); + expect(storedOwnStatus('approved')).toBeUndefined(); + expect(storedOwnStatus('rejected')).toBeUndefined(); + }); + + it('storedOwnTab отдаёт запомненную подвкладку', () => { + persistOwnTab('executed'); + expect(storedOwnTab()).toBe('executed'); + }); + + it('переход в «Мои заявки» кладёт восстановленный фильтр в адрес', () => { + persistOwnStatus('Согласовано'); + const ownTab = storedOwnTab(); + + navigate({ mode: 'submit', nav: 'own', ownTab, ownStatus: storedOwnStatus(ownTab) }); + + expect(window.location.search).toContain('rpstatus='); + expect(parseRoute(window.location.search).ownStatus).toBe('Согласовано'); + }); + + it('переход на подвкладку решений фильтр в адрес не тащит', () => { + persistOwnStatus('Согласовано'); + + navigate({ mode: 'submit', nav: 'own', ownTab: 'approved', ownStatus: storedOwnStatus('approved') }); + + expect(window.location.search).toContain('rptab=approved'); + expect(window.location.search).not.toContain('rpstatus'); + }); + // Ровно тот баг, который это всё затевалось починить: подвкладка «Инициированные» // с выбранным фильтром → уход на другую подвкладку → возврат — фильтр должен // остаться прежним, а не сброситься на «Все статусы» (см. RequestPortal.tsx/ diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index 012b841..6496c66 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -116,6 +116,20 @@ function readStoredPage(viewKey: string): number | undefined { } } +/** + * Запомненная страница таблицы, на которую собираемся перейти. Нужна там, где + * маршрут СТРОИТСЯ (переход по меню/подвкладке): parseRoute подставил бы её и + * сам, но тогда номер остался бы только в состоянии и не попал бы в адрес — + * ссылка на список показывала бы не ту страницу, что открыта на экране. + * + * ownTab не задан (переход в «Мои заявки» из другого раздела) — берём + * запомненную подвкладку: именно она и откроется (см. parseOwnTab). + */ +export function storedPageFor(nav: NavSection, ownTab?: OwnTab): number { + const tab = nav === 'own' ? ownTab ?? readStoredOwnTab() : undefined; + return readStoredPage(viewKeyOf(nav, tab)) ?? 1; +} + /** * Запомнить страницу текущей таблицы. Ключ выводится из самого маршрута — * вызывающей стороне не нужно знать, как таблицы различаются между собой. @@ -152,6 +166,21 @@ function readStoredOwnStatus(): string | undefined { } } +/** + * Запомненные подвкладка и фильтр — как и storedPageFor, нужны там, где маршрут + * СТРОИТСЯ: parseRoute подставил бы их и сам, но тогда они остались бы только в + * состоянии и не попали бы в адрес (ссылка на раздел вела бы не туда, что + * открыто на экране). + */ +export function storedOwnTab(): OwnTab { + return readStoredOwnTab(); +} + +/** Вне вкладки «Инициированные» фильтра по статусу нет по смыслу — там отбор задаёт сама вкладка. */ +export function storedOwnStatus(ownTab: OwnTab): string | undefined { + return ownTab === 'created' ? readStoredOwnStatus() : undefined; +} + /** Запомнить подвкладку «Моих заявок» — вызывать только при явном выборе пользователя. */ export function persistOwnTab(ownTab: OwnTab): void { try { -- 2.34.1 From 926a51482179982c57b94a8c1bd4c425cc92b675 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 10 Aug 2026 17:44:32 +0500 Subject: [PATCH 10/13] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../myRequests/RequestsTableView.module.scss | 12 +++ .../features/myRequests/RequestsTableView.tsx | 41 +++++++-- .../features/myRequests/models/IMyRequest.ts | 6 ++ .../features/myRequests/myRequests.service.ts | 87 +++++++++++++++++++ .../features/myRequests/pageCache.test.ts | 12 +++ .../features/myRequests/pageCache.ts | 4 +- 6 files changed, 155 insertions(+), 7 deletions(-) diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss b/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss index 07b4978..411ad0e 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss @@ -198,6 +198,18 @@ width: 190px; } +// Причина возврата — свободный текст согласующего, поэтому переносим по словам +// (в т.ч. длинное слово без пробелов, иначе оно растянет таблицу). +.colComment { + white-space: normal; + overflow-wrap: anywhere; + color: $gray-700; +} + +.commentEmpty { + color: $gray-400; +} + // Раскраска статуса — теперь только в 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 37c67e5..16b439d 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -9,6 +9,7 @@ import { getOwnRequests, getOwnRequestsByStatus, hydratePendingAuthors, + hydrateReturnComments, searchOwnRequests, } from './myRequests.service'; import { IMyRequest } from './models/IMyRequest'; @@ -115,11 +116,27 @@ function statusOptionClassName(status: string): string { // 'own:created' — статус нужен, только пока показаны ВСЕ заявки. Стоит выбрать // его в фильтре, и колонка вырождается в тот же столбец одинаковых плашек, // поэтому она зависит не от одного вида, а ещё и от фильтра. -function columnsFor(view: ListView, statusFilter: string): { author: boolean; status: boolean } { - if (view === 'approvals' || view === 'execution') return { author: true, status: false }; - if (view === 'rework') return { author: false, status: false }; - if (view === 'own:created') return { author: false, status: !statusFilter }; - return { author: true, status: true }; +// +// «Причина возврата» — там, где показаны ТОЛЬКО возвращённые заявки: для них +// это главное, ради чего в список и заходят (что именно просят исправить). +interface ITableColumns { + author: boolean; + status: boolean; + returnComment: boolean; +} + +function columnsFor(view: ListView, statusFilter: string): ITableColumns { + const returnedOnly = + view === 'rework' || (view === 'own:created' && statusFilter === REQUEST_STATUS.returned); + + if (view === 'approvals' || view === 'execution') { + return { author: true, status: false, returnComment: false }; + } + if (view === 'rework') return { author: false, status: false, returnComment: true }; + if (view === 'own:created') { + return { author: false, status: !statusFilter, returnComment: returnedOnly }; + } + return { author: true, status: true, returnComment: false }; } export interface IRequestsTableViewProps { @@ -171,6 +188,7 @@ const Table: React.FC<{ {columns.author && } {columns.status && } + {columns.returnComment && } @@ -203,6 +221,11 @@ const Table: React.FC<{ {r.status ? : '—'} )} + {columns.returnComment && ( + + )} ))} @@ -323,7 +346,13 @@ export const RequestsTableView: React.FC = ({ // зашитым в раздел, а не выбранным в фильтре. if (view === 'rework' || (view === 'own:created' && statusFilter)) { const status = view === 'rework' ? REQUEST_STATUS.returned : statusFilter; - return getOwnRequestsByStatus(context, types, status, page, debouncedQuery); + const result = await getOwnRequestsByStatus(context, types, status, page, debouncedQuery); + // Причина возврата живёт в строке маршрута — дочитываем только для + // видимой страницы и только там, где колонка показывается. + if (status === REQUEST_STATUS.returned) { + await hydrateReturnComments(context, types, result.rows); + } + return result; } if (view === 'own:created') { diff --git a/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts index 4630ccc..b07f51d 100644 --- a/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts +++ b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts @@ -22,4 +22,10 @@ export interface IMyRequest { * не показывается — поэтому и не входит в pageSignature. */ myStepStatus?: string; + /** + * Комментарий согласующего, вернувшего заявку на доработку. Живёт не в списке + * данных, а в строке МАРШРУТА, поэтому дочитывается отдельно и только там, + * где нужен (см. hydrateReturnComments). + */ + returnComment?: string; } diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 47d4edf..09ebc51 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -785,6 +785,93 @@ export async function getOwnRequestsByStatus( }; } +// Список согласования по типу заявки: Title списка данных → Title списка +// согласования по конвенции имён (та же логика, что в indexApprovalsForType). +// undefined — список заведён не по конвенции, маршрута для него не найти. +async function approvalListFor( + context: WebPartContext, + type: IRequestType +): Promise { + const sp = spForSite(type.siteUrl as string, context); + const info = (await sp.web.getList(type.listId as string).select('Title')()) as { Title?: string }; + const dataTitle = info.Title || ''; + if (!DATA_LIST_SUFFIX.test(dataTitle)) return undefined; + return sp.web.lists.getByTitle(`${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`); +} + +// Сколько строк маршрута максимум забираем за раз при дочитывании комментариев: +// на странице ≤20 заявок, у каждой обычно единицы шагов — запас многократный. +const RETURN_COMMENT_ROW_LIMIT = 2000; + +/** + * Проставляет returnComment — комментарий согласующего, вернувшего заявку на + * доработку. Причина возврата хранится в СТРОКЕ МАРШРУТА (колонка Comment + * списка «— согласование»), а не в самой заявке, поэтому её нельзя получить + * тем же запросом, что и список: нужен отдельный проход по спискам согласования. + * + * Вызывается ТОЛЬКО там, где причина показывается (см. RequestsTableView) — + * счётчику в меню, например, она не нужна, и лишние запросы ему ни к чему. + * Мутирует переданные строки — как и hydrateAuthors рядом. + */ +export async function hydrateReturnComments( + context: WebPartContext, + types: IRequestType[], + rows: IMyRequest[] +): Promise { + if (rows.length === 0) return; + + const typeById = new Map(); + types.forEach((t) => typeById.set(t.id, t)); + + const rowsByType = new Map(); + rows.forEach((r) => { + const list = rowsByType.get(r.requestTypeId) || []; + list.push(r); + rowsByType.set(r.requestTypeId, list); + }); + + await Promise.all( + Array.from(rowsByType.entries()).map(async ([requestTypeId, typeRows]) => { + const type = typeById.get(requestTypeId); + if (!type) return; + try { + const approvalList = await approvalListFor(context, type); + if (!approvalList) return; + + // Фильтруем только по TicketId (числовое поле), статус сравниваем в JS: + // кириллица в $filter по Choice-колонке молча возвращает 0 строк — + // ровно та же причина, что и в indexApprovalsForType. + const stepRows = (await approvalList.items + .filter(typeRows.map((r) => `TicketId eq ${r.id}`).join(' or ')) + .select('TicketId', 'ApprovalStatus', 'Comment') + .top(RETURN_COMMENT_ROW_LIMIT)()) as Array>; + + const commentByTicket = new Map(); + stepRows.forEach((r) => { + if (r.ApprovalStatus !== REQUEST_STATUS.returned) return; + const ticketId = r.TicketId as number | undefined; + const comment = ((r.Comment as string) || '').trim(); + // Возврат по заявке один (при переотправке маршрут переписывается + // целиком, см. writeRoute) — но если строк вдруг несколько, берём + // первую непустую, а не последнюю пустую. + if (ticketId && comment && !commentByTicket.has(ticketId)) { + commentByTicket.set(ticketId, comment); + } + }); + + typeRows.forEach((r) => { + r.returnComment = commentByTicket.get(r.id); + }); + } catch (e) { + console.error( + `[Текущие заявки] «${type.name}»: не удалось прочитать причину возврата:`, + (e as Error).message + ); + } + }) + ); +} + // «На согласовании»: список заявок уже отобран по строкам согласования // (getPendingApprovalIndex — там же определяется, чья сейчас очередь, // обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search diff --git a/src/webparts/requestPortal/features/myRequests/pageCache.test.ts b/src/webparts/requestPortal/features/myRequests/pageCache.test.ts index 729e595..153ad1c 100644 --- a/src/webparts/requestPortal/features/myRequests/pageCache.test.ts +++ b/src/webparts/requestPortal/features/myRequests/pageCache.test.ts @@ -99,6 +99,18 @@ describe('pageSignature', () => { expect(pageSignature(before)).toBe(pageSignature(after)); }); + it('изменение причины возврата меняет подпись — она видна в таблице «Доработка»', () => { + const before = page({ rows: [request({ returnComment: 'Исправьте должность' })] }); + const after = page({ rows: [request({ returnComment: 'Приложите скан' })] }); + expect(pageSignature(before)).not.toBe(pageSignature(after)); + }); + + it('появление причины возврата у строки без неё тоже меняет подпись', () => { + const before = page({ rows: [request()] }); + const after = page({ rows: [request({ returnComment: 'Исправьте должность' })] }); + expect(pageSignature(before)).not.toBe(pageSignature(after)); + }); + it('пустая страница имеет стабильную подпись', () => { expect(pageSignature({ rows: [], totalRows: 0 })).toBe(pageSignature({ rows: [], totalRows: 0 })); }); diff --git a/src/webparts/requestPortal/features/myRequests/pageCache.ts b/src/webparts/requestPortal/features/myRequests/pageCache.ts index 8159322..52d1569 100644 --- a/src/webparts/requestPortal/features/myRequests/pageCache.ts +++ b/src/webparts/requestPortal/features/myRequests/pageCache.ts @@ -38,6 +38,8 @@ export function writePage(key: string, page: IPage): void { // перерисовываясь тем же самым содержимым. export function pageSignature(page: IPage): string { return `${page.totalRows}|${page.rows - .map((r) => [r.requestTypeId, r.id, r.title, r.status, r.created, r.authorName].join('~')) + .map((r) => + [r.requestTypeId, r.id, r.title, r.status, r.created, r.authorName, r.returnComment ?? ''].join('~') + ) .join(';')}`; } -- 2.34.1 From 7ef0ee62457da834d1ed2bc827ccddac57bee81b Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 10 Aug 2026 17:52:25 +0500 Subject: [PATCH 11/13] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/myRequests/RequestsTableView.tsx | 76 +++++++++++++------ .../features/myRequests/models/IMyRequest.ts | 9 ++- .../features/myRequests/myRequests.service.ts | 36 +++++---- .../features/myRequests/pageCache.test.ts | 10 +-- .../features/myRequests/pageCache.ts | 2 +- src/webparts/requestPortal/shared/routing.ts | 2 + 6 files changed, 86 insertions(+), 49 deletions(-) diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index 16b439d..1fdbe95 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -8,8 +8,8 @@ import { getLinkedTypes, getOwnRequests, getOwnRequestsByStatus, + hydrateDecisionComments, hydratePendingAuthors, - hydrateReturnComments, searchOwnRequests, } from './myRequests.service'; import { IMyRequest } from './models/IMyRequest'; @@ -117,26 +117,45 @@ function statusOptionClassName(status: string): string { // его в фильтре, и колонка вырождается в тот же столбец одинаковых плашек, // поэтому она зависит не от одного вида, а ещё и от фильтра. // -// «Причина возврата» — там, где показаны ТОЛЬКО возвращённые заявки: для них -// это главное, ради чего в список и заходят (что именно просят исправить). +// Статус решения, ОБЩИЙ для всех строк раздела, — если он есть, показывать его +// колонкой незачем (столбец одинаковых плашек), зато полезен комментарий +// согласующего: почему вернули или почему отклонили. Именно за этим в такие +// списки и заходят. +function decisionStatusFor(view: ListView, statusFilter: string): string | undefined { + if (view === 'rework') return REQUEST_STATUS.returned; + if (view === 'own:rejected') return REQUEST_STATUS.rejected; + if ( + view === 'own:created' && + (statusFilter === REQUEST_STATUS.returned || statusFilter === REQUEST_STATUS.rejected) + ) { + return statusFilter; + } + return undefined; +} + interface ITableColumns { author: boolean; status: boolean; - returnComment: boolean; + /** Заголовок колонки с комментарием; undefined — колонки нет. */ + commentLabel?: string; } function columnsFor(view: ListView, statusFilter: string): ITableColumns { - const returnedOnly = - view === 'rework' || (view === 'own:created' && statusFilter === REQUEST_STATUS.returned); + const decisionStatus = decisionStatusFor(view, statusFilter); + const commentLabel = + decisionStatus === REQUEST_STATUS.rejected + ? 'Причина отклонения' + : decisionStatus === REQUEST_STATUS.returned + ? 'Причина возврата' + : undefined; - if (view === 'approvals' || view === 'execution') { - return { author: true, status: false, returnComment: false }; - } - if (view === 'rework') return { author: false, status: false, returnComment: true }; - if (view === 'own:created') { - return { author: false, status: !statusFilter, returnComment: returnedOnly }; - } - return { author: true, status: true, returnComment: false }; + if (view === 'approvals' || view === 'execution') return { author: true, status: false }; + if (view === 'rework') return { author: false, status: false, commentLabel }; + if (view === 'own:created') return { author: false, status: !statusFilter, commentLabel }; + // История решений: заявка чужая, поэтому нужен автор. Статус — только если он + // не предопределён самой вкладкой («Отклонённые мной» — это всегда «Отклонено», + // а вот у согласованных/выполненных заявка могла уйти дальше по маршруту). + return { author: true, status: !decisionStatus, commentLabel }; } export interface IRequestsTableViewProps { @@ -188,7 +207,7 @@ const Table: React.FC<{ {columns.author && } {columns.status && } - {columns.returnComment && } + {columns.commentLabel && } @@ -221,9 +240,9 @@ const Table: React.FC<{ {r.status ? : '—'} )} - {columns.returnComment && ( + {columns.commentLabel && ( )} @@ -341,18 +360,12 @@ export const RequestsTableView: React.FC = ({ const q = debouncedQuery.trim(); - const fetchPage = async (): Promise => { + const fetchRows = async (): Promise => { // «Доработка» — тот же список, что «Инициированные», просто со статусом, // зашитым в раздел, а не выбранным в фильтре. if (view === 'rework' || (view === 'own:created' && statusFilter)) { const status = view === 'rework' ? REQUEST_STATUS.returned : statusFilter; - const result = await getOwnRequestsByStatus(context, types, status, page, debouncedQuery); - // Причина возврата живёт в строке маршрута — дочитываем только для - // видимой страницы и только там, где колонка показывается. - if (status === REQUEST_STATUS.returned) { - await hydrateReturnComments(context, types, result.rows); - } - return result; + return getOwnRequestsByStatus(context, types, status, page, debouncedQuery); } if (view === 'own:created') { @@ -378,6 +391,19 @@ export const RequestsTableView: React.FC = ({ return { rows: pageRows, totalRows: filtered.length }; }; + // Комментарий к решению дочитываем ОДНИМ местом на все ветки: «Доработка» и + // «Отклонённые мной» приходят разными путями (по автору и по строкам + // маршрута соответственно), а колонка у них одинаковая по смыслу. И только + // для видимой страницы — как и автор в hydratePendingAuthors. + const fetchPage = async (): Promise => { + const result = await fetchRows(); + const decisionStatus = decisionStatusFor(view, statusFilter); + if (decisionStatus) { + await hydrateDecisionComments(context, types, result.rows, decisionStatus); + } + return result; + }; + const load = async (): Promise => { try { const fresh = await fetchPage(); diff --git a/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts index b07f51d..d53c5ba 100644 --- a/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts +++ b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts @@ -23,9 +23,10 @@ export interface IMyRequest { */ myStepStatus?: string; /** - * Комментарий согласующего, вернувшего заявку на доработку. Живёт не в списке - * данных, а в строке МАРШРУТА, поэтому дочитывается отдельно и только там, - * где нужен (см. hydrateReturnComments). + * Комментарий согласующего к решению — причина возврата на доработку или + * отклонения (какое именно решение, задаёт вызывающая сторона). Живёт не в + * списке данных, а в строке МАРШРУТА, поэтому дочитывается отдельно и только + * там, где показывается (см. hydrateDecisionComments). */ - returnComment?: string; + decisionComment?: string; } diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 09ebc51..fa24ad2 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -801,22 +801,29 @@ async function approvalListFor( // Сколько строк маршрута максимум забираем за раз при дочитывании комментариев: // на странице ≤20 заявок, у каждой обычно единицы шагов — запас многократный. -const RETURN_COMMENT_ROW_LIMIT = 2000; +const DECISION_COMMENT_ROW_LIMIT = 2000; /** - * Проставляет returnComment — комментарий согласующего, вернувшего заявку на - * доработку. Причина возврата хранится в СТРОКЕ МАРШРУТА (колонка Comment - * списка «— согласование»), а не в самой заявке, поэтому её нельзя получить - * тем же запросом, что и список: нужен отдельный проход по спискам согласования. + * Проставляет decisionComment — комментарий согласующего к его решению по + * заявке: причину возврата на доработку либо отклонения (что именно искать, + * задаёт decisionStatus). Комментарий хранится в СТРОКЕ МАРШРУТА (колонка + * Comment списка «— согласование»), а не в самой заявке, поэтому его нельзя + * получить тем же запросом, что и список: нужен отдельный проход по спискам + * согласования. + * + * Одного статуса достаточно, чтобы найти нужную строку, и для «своих + * возвращённых» (вернул кто-то другой), и для «отклонённых мной»: и возврат, и + * отклонение останавливают маршрут, поэтому такая строка по заявке одна. * * Вызывается ТОЛЬКО там, где причина показывается (см. RequestsTableView) — * счётчику в меню, например, она не нужна, и лишние запросы ему ни к чему. * Мутирует переданные строки — как и hydrateAuthors рядом. */ -export async function hydrateReturnComments( +export async function hydrateDecisionComments( context: WebPartContext, types: IRequestType[], - rows: IMyRequest[] + rows: IMyRequest[], + decisionStatus: string ): Promise { if (rows.length === 0) return; @@ -844,27 +851,28 @@ export async function hydrateReturnComments( const stepRows = (await approvalList.items .filter(typeRows.map((r) => `TicketId eq ${r.id}`).join(' or ')) .select('TicketId', 'ApprovalStatus', 'Comment') - .top(RETURN_COMMENT_ROW_LIMIT)()) as Array>; + .top(DECISION_COMMENT_ROW_LIMIT)()) as Array>; const commentByTicket = new Map(); stepRows.forEach((r) => { - if (r.ApprovalStatus !== REQUEST_STATUS.returned) return; + if (r.ApprovalStatus !== decisionStatus) return; const ticketId = r.TicketId as number | undefined; const comment = ((r.Comment as string) || '').trim(); - // Возврат по заявке один (при переотправке маршрут переписывается - // целиком, см. writeRoute) — но если строк вдруг несколько, берём - // первую непустую, а не последнюю пустую. + // Такая строка по заявке одна (и возврат, и отклонение останавливают + // маршрут, а при переотправке он переписывается целиком — см. + // writeRoute), но если их вдруг несколько, берём первую НЕПУСТУЮ, + // а не последнюю пустую. if (ticketId && comment && !commentByTicket.has(ticketId)) { commentByTicket.set(ticketId, comment); } }); typeRows.forEach((r) => { - r.returnComment = commentByTicket.get(r.id); + r.decisionComment = commentByTicket.get(r.id); }); } catch (e) { console.error( - `[Текущие заявки] «${type.name}»: не удалось прочитать причину возврата:`, + `[Текущие заявки] «${type.name}»: не удалось прочитать комментарий к решению:`, (e as Error).message ); } diff --git a/src/webparts/requestPortal/features/myRequests/pageCache.test.ts b/src/webparts/requestPortal/features/myRequests/pageCache.test.ts index 153ad1c..26c4421 100644 --- a/src/webparts/requestPortal/features/myRequests/pageCache.test.ts +++ b/src/webparts/requestPortal/features/myRequests/pageCache.test.ts @@ -99,15 +99,15 @@ describe('pageSignature', () => { expect(pageSignature(before)).toBe(pageSignature(after)); }); - it('изменение причины возврата меняет подпись — она видна в таблице «Доработка»', () => { - const before = page({ rows: [request({ returnComment: 'Исправьте должность' })] }); - const after = page({ rows: [request({ returnComment: 'Приложите скан' })] }); + it('изменение комментария к решению меняет подпись — он показан колонкой «Причина возврата/отклонения»', () => { + const before = page({ rows: [request({ decisionComment: 'Исправьте должность' })] }); + const after = page({ rows: [request({ decisionComment: 'Приложите скан' })] }); expect(pageSignature(before)).not.toBe(pageSignature(after)); }); - it('появление причины возврата у строки без неё тоже меняет подпись', () => { + it('появление комментария к решению у строки без него тоже меняет подпись', () => { const before = page({ rows: [request()] }); - const after = page({ rows: [request({ returnComment: 'Исправьте должность' })] }); + const after = page({ rows: [request({ decisionComment: 'Исправьте должность' })] }); expect(pageSignature(before)).not.toBe(pageSignature(after)); }); diff --git a/src/webparts/requestPortal/features/myRequests/pageCache.ts b/src/webparts/requestPortal/features/myRequests/pageCache.ts index 52d1569..7d0fbb9 100644 --- a/src/webparts/requestPortal/features/myRequests/pageCache.ts +++ b/src/webparts/requestPortal/features/myRequests/pageCache.ts @@ -39,7 +39,7 @@ export function writePage(key: string, page: IPage): void { export function pageSignature(page: IPage): string { return `${page.totalRows}|${page.rows .map((r) => - [r.requestTypeId, r.id, r.title, r.status, r.created, r.authorName, r.returnComment ?? ''].join('~') + [r.requestTypeId, r.id, r.title, r.status, r.created, r.authorName, r.decisionComment ?? ''].join('~') ) .join(';')}`; } diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index 6496c66..6793eac 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -60,6 +60,8 @@ export interface IRoute { ownStatus?: string; /** Номер страницы таблицы (1-based). 1 — значение по умолчанию, в адрес не пишется. */ page?: number; + /** Текст поиска по таблице. Пустой — значение по умолчанию, в адрес не пишется. */ + query?: string; } const ROUTE_CHANGE_EVENT = 'rp-route-change'; -- 2.34.1 From 67c86576189a4df5c59e28026b524eb87a23c211 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 10 Aug 2026 18:18:42 +0500 Subject: [PATCH 12/13] =?UTF-8?q?=D1=83=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=80=D0=BE=D1=83=D1=82=D0=B8=D0=BD=D0=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/RequestPortal.tsx | 54 ++++++++- .../features/myRequests/RequestsTableView.tsx | 38 +++--- .../features/myRequests/SearchView.tsx | 108 ++++++++++++++---- .../features/myRequests/myRequests.service.ts | 30 ++--- .../requestPortal/shared/routing.test.ts | 91 ++++++++++++++- src/webparts/requestPortal/shared/routing.ts | 57 ++++++++- .../requestPortal/shared/usePersistedQuery.ts | 102 +++++++++-------- 7 files changed, 365 insertions(+), 115 deletions(-) diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index a01f513..f5df610 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -7,9 +7,11 @@ import { persistOwnTab, persistOwnStatus, persistPage, + persistQuery, storedOwnStatus, storedOwnTab, storedPageFor, + storedQueryFor, useRoute, NavSection, OwnTab, @@ -137,10 +139,16 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context ownTab, ownStatus: storedOwnStatus(ownTab), page: storedPageFor('own', ownTab), + query: storedQueryFor('own', ownTab) || undefined, }); return; } - navigate({ mode: 'submit', nav, page: storedPageFor(nav) }); + navigate({ + mode: 'submit', + nav, + page: storedPageFor(nav), + query: storedQueryFor(nav) || undefined, + }); }; const gotoOwnTab = (ownTab: OwnTab): void => { @@ -154,6 +162,7 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context ownTab, ownStatus: storedOwnStatus(ownTab), page: storedPageFor('own', ownTab), + query: storedQueryFor('own', ownTab) || undefined, }); }; @@ -163,7 +172,19 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context // Смена фильтра — другой набор заявок, поэтому явно с первой страницы // (и запоминаем именно её, иначе вернувшись сюда попали бы на старую). persistPage({ mode: 'submit', nav: 'own', ownTab: 'created' }, 1); - navigate({ mode: 'submit', nav: 'own', ownTab: 'created', ownStatus: normalized, page: 1 }); + // Текст поиска переносим ЯВНО: раздел тот же, поле на экране не очищается — + // значит и в адресе запрос должен остаться. Без этого rpq пропадал из + // ссылки при каждой смене фильтра (в самом состоянии он всё равно + // восстанавливался из запомненного, поэтому расхождение адреса с экраном + // было видно не сразу). + navigate({ + mode: 'submit', + nav: 'own', + ownTab: 'created', + ownStatus: normalized, + page: 1, + query: route.query || undefined, + }); }; // Смена страницы сохраняет ВЕСЬ остальной маршрут (в т.ч. открытую заявку), @@ -173,6 +194,18 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context persistPage(route, page); navigate({ ...route, page }); }; + + // Новый запрос — другой набор результатов, поэтому вместе с ним всегда первая + // страница (иначе можно приземлиться на несуществующую). + // + // replace, а не push: текст уходит сюда после КАЖДОЙ паузы в наборе, и с + // обычным переходом каждое слово становилось бы отдельным шагом «назад» — + // чтобы уйти со страницы, пришлось бы «отлистать» весь набранный запрос. + const setQuery = (query: string): void => { + persistQuery(route, query); + persistPage(route, 1); + navigate({ ...route, query: query || undefined, page: 1 }, { replace: true }); + }; // Клик по строке таблицы. СВОЯ заявка в незавершённом статусе (черновик, // возвращённая, отклонённая) открывается на редактирование, всё остальное — // на просмотр: чужую заявку править нельзя, а свою уже ушедшую по маршруту — @@ -185,12 +218,23 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context itemId: request.id, ownTab: route.ownTab, ownStatus: route.ownStatus, + // Страница и запрос — часть того же списка под панелью: без них ссылка на + // открытую заявку вела бы на первую страницу без поиска. + page: route.page, + query: route.query, }); // Закрывая панель, возвращаемся ровно в тот список, из которого её открыли — // включая подвкладку и фильтр «Моих заявок». const closeDrawer = (): void => - navigate({ mode: 'submit', nav: route.nav, ownTab: route.ownTab, ownStatus: route.ownStatus }); + navigate({ + mode: 'submit', + nav: route.nav, + ownTab: route.ownTab, + ownStatus: route.ownStatus, + page: route.page, + query: route.query, + }); // Закрытие панели РЕДАКТИРОВАНИЯ: там открыта форма, и её содержимое живёт // только в памяти — уходя без сохранения, пользователь теряет правки. @@ -263,6 +307,8 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context context={context} page={route.page ?? 1} onPageChange={setPage} + query={route.query ?? ''} + onQueryChange={setQuery} onOpen={openRequest} /> )} @@ -283,6 +329,8 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context onStatusFilterChange={setOwnStatus} page={route.page ?? 1} onPageChange={setPage} + query={route.query ?? ''} + onQueryChange={setQuery} onOpen={openRequest} /> )} diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index 1fdbe95..6b1f8a8 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -15,7 +15,7 @@ import { import { IMyRequest } from './models/IMyRequest'; import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType, StatusBadge } from '../../shared/approval'; import { onRequestsChanged } from '../../shared/requestEvents'; -import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery'; +import { useRoutedQuery } from '../../shared/usePersistedQuery'; import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache'; import { Pager } from './Pager'; import styles from './RequestsTableView.module.scss'; @@ -172,6 +172,12 @@ export interface IRequestsTableViewProps { */ page: number; onPageChange: (page: number) => void; + /** + * Текст поиска и его смена. Тоже живёт в маршруте (см. page выше): попадает + * в адрес, переживает открытие заявки и запоминается для этой таблицы. + */ + query: string; + onQueryChange: (query: string) => void; /** Клик по строке — открыть заявку (в выезжающей панели). */ onOpen: (request: IMyRequest) => void; } @@ -291,18 +297,20 @@ export const RequestsTableView: React.FC = ({ onStatusFilterChange, page, onPageChange, + query, + onQueryChange, onOpen, }) => { const [types, setTypes] = React.useState(undefined); const [rows, setRows] = React.useState(undefined); const [totalRows, setTotalRows] = React.useState(0); - const [query, setQuery] = usePersistedQuery(view); const [error, setError] = React.useState(''); - // query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что - // уходит в запрос (после паузы в наборе). Всё ниже завязано на debouncedQuery, - // иначе поиск бил бы запросом по каждой набранной букве. - const debouncedQuery = useDebouncedQuery(query); + // input — то, что в поле ввода (меняется сразу); в маршрут значение уходит + // после паузы в наборе. Данные грузятся по МАРШРУТНОМУ query — он и так уже + // «отложенный», отдельный debounce здесь был бы вторым по счёту. + const [input, setInput] = useRoutedQuery(query, onQueryChange); + const debouncedQuery = query; // Какой ИМЕННО таблице принадлежат лежащие в состоянии rows/totalRows. // Компонент один на все разделы (см. LIST_VIEWS в RequestPortal), поэтому при @@ -314,18 +322,8 @@ export const RequestsTableView: React.FC = ({ const dataKey = `${view}|${statusFilter}|${debouncedQuery.trim()}`; const [loadedKey, setLoadedKey] = React.useState(''); - // Смена ЗАПРОСА сбрасывает страницу: набор результатов другой, и «страница 3» - // от прошлого запроса к нему отношения не имеет. Смену вкладки/фильтра здесь - // НЕ обрабатываем — там страница приходит уже правильной из маршрута - // (см. RequestPortal: gotoOwnTab/setOwnStatus). Сравниваем с предыдущим - // значением, а не просто вешаемся на изменение: иначе эффект сбрасывал бы - // страницу и на первом рендере, затирая ту, что пришла из адреса. - const prevQuery = React.useRef(debouncedQuery); - React.useEffect(() => { - if (prevQuery.current === debouncedQuery) return; - prevQuery.current = debouncedQuery; - if (page !== 1) onPageChange(1); - }, [debouncedQuery, page, onPageChange]); + // Страницу при смене запроса здесь НЕ сбрасываем: и запрос, и страница живут + // в маршруте, и меняются они одним переходом (см. setQuery в RequestPortal). React.useEffect(() => { let cancelled = false; @@ -491,8 +489,8 @@ export const RequestsTableView: React.FC = ({ type="search" className={styles.search} placeholder="Поиск по заявкам…" - value={query} - onChange={(e) => setQuery(e.target.value)} + value={input} + onChange={(e) => setInput(e.target.value)} /> diff --git a/src/webparts/requestPortal/features/myRequests/SearchView.tsx b/src/webparts/requestPortal/features/myRequests/SearchView.tsx index 38ae872..7038d4a 100644 --- a/src/webparts/requestPortal/features/myRequests/SearchView.tsx +++ b/src/webparts/requestPortal/features/myRequests/SearchView.tsx @@ -1,10 +1,10 @@ import * as React from 'react'; import type { WebPartContext } from '@microsoft/sp-webpart-base'; -import { getLinkedTypes, searchMyRequests } from './myRequests.service'; +import { getLinkedTypes, hydratePendingAuthors, searchMyRequests } from './myRequests.service'; import { IMyRequest } from './models/IMyRequest'; import { IRequestType, StatusBadge } from '../../shared/approval'; import { onRequestsChanged } from '../../shared/requestEvents'; -import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery'; +import { useRoutedQuery } from '../../shared/usePersistedQuery'; import { pageKey, pageSignature, readPage, writePage } from './pageCache'; import { Pager } from './Pager'; import styles from './SearchView.module.scss'; @@ -26,10 +26,29 @@ export interface ISearchViewProps { */ page: number; onPageChange: (page: number) => void; + /** Текст поиска и его смена — тоже из маршрута (см. page выше). */ + query: string; + onQueryChange: (query: string) => void; /** Клик по строке — открыть заявку (в выезжающей панели). */ onOpen: (request: IMyRequest) => void; } +const rowKey = (r: IMyRequest): string => `${r.requestTypeId}-${r.id}`; + +// Автор, уже дочитанный ранее (см. authors в SearchView), — проставляем его в +// свежий результат поиска до сравнения с кешем. Иначе фоновое обновление +// приносило бы строки без автора, слепок страницы отличался бы от кешированного +// на ровном месте, таблица моргала бы и автор загружался заново. +function applyKnownAuthors(rows: IMyRequest[], known: Map): void { + rows.forEach((r) => { + const a = known.get(rowKey(r)); + if (a) { + r.authorName = a.name; + r.authorEmail = a.email; + } + }); +} + function formatDate(iso: string): string { if (!iso) return ''; const d = new Date(iso); @@ -100,27 +119,33 @@ const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => voi // после решения согласующего) — но т.к. это по большей части Search, свежая // правка может проявиться не мгновенно (частично компенсируется довеском // последних 10 минут через REST — см. myRequests.service.ts). -export const SearchView: React.FC = ({ context, page, onPageChange, onOpen }) => { +export const SearchView: React.FC = ({ + context, + page, + onPageChange, + query, + onQueryChange, + onOpen, +}) => { const [types, setTypes] = React.useState(undefined); const [rows, setRows] = React.useState(undefined); const [totalRows, setTotalRows] = React.useState(0); - const [query, setQuery] = usePersistedQuery('search'); - // query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что - // уходит в запрос (после паузы в наборе). Здесь это особенно важно: поиск - // ходит сразу по двум источникам (свои заявки + где участвую), то есть - // каждая набранная буква стоила бы двух серий запросов. - const debouncedQuery = useDebouncedQuery(query); const [error, setError] = React.useState(''); - // Новый запрос — новый набор результатов, страница от прошлого к нему не - // относится. Сравниваем с предыдущим значением, чтобы не сбросить страницу, - // пришедшую из адреса, на первом же рендере (см. RequestsTableView). - const prevQuery = React.useRef(debouncedQuery); - React.useEffect(() => { - if (prevQuery.current === debouncedQuery) return; - prevQuery.current = debouncedQuery; - if (page !== 1) onPageChange(1); - }, [debouncedQuery, page, onPageChange]); + // Дочитанные авторы, ключ — requestTypeId-id. Живёт между запросами намеренно: + // ключ глобально уникален, так что при возврате к прежнему поиску автор уже + // известен и запрос не повторяется. + const authors = React.useRef>(new Map()); + + // input — то, что в поле ввода (меняется сразу); в маршрут значение уходит + // после паузы в наборе. Здесь пауза особенно важна: поиск ходит сразу по двум + // источникам (свои заявки + где участвую), то есть каждая набранная буква + // стоила бы двух серий запросов. + const [input, setInput] = useRoutedQuery(query, onQueryChange); + const debouncedQuery = query; + + // Страницу при смене запроса здесь НЕ сбрасываем — это делает setQuery в + // RequestPortal одним переходом вместе с самим запросом. React.useEffect(() => { let cancelled = false; @@ -136,6 +161,11 @@ export const SearchView: React.FC = ({ context, page, onPageCh }; }, [context]); + // Поиск зависит ТОЛЬКО от запроса, не от страницы: результат приходит целиком + // и кешируется целиком, а страницы нарезаются ниже, в рендере. Раньше страница + // входила в ключ кеша — и каждое листание было промахом, который заново гонял + // обе серии запросов (свои + где участвую) и показывал «Ищем…», хотя все эти + // строки уже были получены при загрузке первой страницы. React.useEffect(() => { if (types === undefined) return; if (debouncedQuery.trim().length === 0) { @@ -144,7 +174,7 @@ export const SearchView: React.FC = ({ context, page, onPageCh return; } let cancelled = false; - const cacheKey = pageKey('search', [debouncedQuery.trim(), page]); + const cacheKey = pageKey('search', [debouncedQuery.trim()]); // Уже искали это раньше — показываем прежний результат сразу, обновление // идёт фоном (см. RequestsTableView, там та же схема и те же причины). @@ -154,8 +184,9 @@ export const SearchView: React.FC = ({ context, page, onPageCh const load = async (): Promise => { try { - const fresh = await searchMyRequests(context, types, debouncedQuery, page); + const fresh = await searchMyRequests(context, types, debouncedQuery); if (cancelled) return; + applyKnownAuthors(fresh.rows, authors.current); const prev = readPage(cacheKey); if (!prev || pageSignature(prev) !== pageSignature(fresh)) { writePage(cacheKey, fresh); @@ -180,10 +211,39 @@ export const SearchView: React.FC = ({ context, page, onPageCh unsubscribe(); window.clearInterval(timer); }; - }, [context, types, debouncedQuery, page]); + }, [context, types, debouncedQuery]); const hasQuery = debouncedQuery.trim().length > 0; const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE)); + const visible = React.useMemo( + () => (rows ? rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) : undefined), + [rows, page] + ); + + // Автора у строк 'approval' дочитываем отдельным запросом и только для видимой + // страницы: на весь набор (до 300 строк) это был бы $filter из сотен Id — + // такой длины URL SharePoint не переваривает. Строки мутируются на месте, то + // есть автор оседает и в кеше; карта authors не даёт дёргать один и тот же + // запрос повторно (в т.ч. когда автор оказался пустым). + React.useEffect(() => { + if (types === undefined || visible === undefined) return undefined; + const need = visible.filter((r) => r.kind === 'approval' && !authors.current.has(rowKey(r))); + if (need.length === 0) return undefined; + + let cancelled = false; + hydratePendingAuthors(context, types, need) + .then(() => { + need.forEach((r) => + authors.current.set(rowKey(r), { name: r.authorName ?? '', email: r.authorEmail ?? '' }) + ); + if (!cancelled) setRows((prev) => (prev ? [...prev] : prev)); + }) + .catch(() => undefined); + + return () => { + cancelled = true; + }; + }, [context, types, visible]); return (
@@ -196,8 +256,8 @@ export const SearchView: React.FC = ({ context, page, onPageCh type="search" className={styles.search} placeholder="Введите текст — ищем по своим заявкам и по тем, где вы участвуете…" - value={query} - onChange={(e) => setQuery(e.target.value)} + value={input} + onChange={(e) => setInput(e.target.value)} autoFocus /> @@ -210,7 +270,7 @@ export const SearchView: React.FC = ({ context, page, onPageCh )} {!error && hasQuery && rows && totalRows > 0 && ( <> -
ДатаАвторСтатусПричина возврата
+ {r.returnComment || Без комментария} +
ДатаАвторСтатусПричина возврата{columns.commentLabel}
- {r.returnComment || Без комментария} + {r.decisionComment || Без комментария}
+
)} diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index fa24ad2..a284f12 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -973,11 +973,25 @@ export async function searchOwnAll( return entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r); } +/** + * ВЕСЬ результат поиска (без нарезки на страницы) — постраничность делает + * вызывающая сторона. + * + * Именно так, а не «страницу за вызов»: набор всё равно собирается целиком + * (двумя источниками + слияние + сортировка), и раньше 95% этой работы + * выбрасывалось ради 20 строк — а при переходе на следующую страницу весь поиск + * запускался заново. Теперь SearchView кеширует список по ЗАПРОСУ, и листание + * страниц не стоит ни одного запроса. + * + * Автор здесь НЕ дочитывается: у строк 'approval' он требует отдельного запроса, + * и делать его на весь набор (до KQL_ID_LIMIT) значило бы собирать $filter из + * сотен Id — SharePoint такой длины URL не переварит. Автора дочитывает + * вызывающая сторона для видимой страницы (hydratePendingAuthors). + */ export async function searchMyRequests( context: WebPartContext, types: IRequestType[], - query: string, - page: number + query: string ): Promise { const q = query.trim(); if (!q) return { rows: [], totalRows: 0 }; @@ -995,17 +1009,7 @@ export async function searchMyRequests( const involvedOnly = involvedFiltered.filter((r) => !ownKeys.has(`${r.requestTypeId}-${r.id}`)); const merged = ownRows.concat(involvedOnly).sort((a, b) => b.created.localeCompare(a.created)); - const totalRows = merged.length; - const pageRows = merged.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); - - // Автор нужен для 'approval'-строк текущей страницы — 'own' уже пришли с - // автором (loadByIds сам дочитывает Author). - const needAuthor = pageRows.filter((r) => r.kind === 'approval'); - if (needAuthor.length > 0) { - await hydratePendingAuthors(context, involvedIdx.types, needAuthor); - } - - return { rows: pageRows, totalRows }; + return { rows: merged, totalRows: merged.length }; } function toMyRequest( diff --git a/src/webparts/requestPortal/shared/routing.test.ts b/src/webparts/requestPortal/shared/routing.test.ts index 924c292..9a94f74 100644 --- a/src/webparts/requestPortal/shared/routing.test.ts +++ b/src/webparts/requestPortal/shared/routing.test.ts @@ -5,10 +5,12 @@ import { persistOwnStatus, persistOwnTab, persistPage, + persistQuery, routeUrl, storedOwnStatus, storedOwnTab, storedPageFor, + storedQueryFor, } from './routing'; // buildQuery/navigate читают и пишут window.location через history API, а @@ -23,7 +25,7 @@ beforeEach(() => { describe('parseRoute', () => { it('пустой адрес — подача заявки в разделе «Новая заявка»', () => { // page всегда определён (1 — значение по умолчанию), в адрес не пишется. - expect(parseRoute('')).toEqual({ mode: 'submit', nav: 'new', page: 1 }); + expect(parseRoute('')).toEqual({ mode: 'submit', nav: 'new', page: 1, query: '' }); }); it('rpnav задаёт раздел бокового меню', () => { @@ -45,6 +47,7 @@ describe('parseRoute', () => { formKey: 'hire', itemId: 42, page: 1, + query: '', }); }); @@ -59,6 +62,7 @@ describe('parseRoute', () => { formKey: 'hire', itemId: 7, page: 1, + query: '', }); }); @@ -252,6 +256,90 @@ describe('страница таблицы (rppage + persistPage)', () => { }); }); +describe('поиск (rpq + persistQuery)', () => { + it('без rpq — пустой запрос', () => { + expect(parseRoute('?rpnav=own').query).toBe(''); + }); + + it('rpq задаёт запрос, в т.ч. с пробелами и кириллицей', () => { + expect(parseRoute('?rpnav=own&rpq=%D0%B0%D0%B1%D0%B2%20%D0%B3').query).toBe('абв г'); + }); + + it('пустой запрос не засоряет адрес, непустой — попадает', () => { + expect(routeUrl({ mode: 'submit', nav: 'own', query: '' })).not.toContain('rpq'); + expect(routeUrl({ mode: 'submit', nav: 'own' })).not.toContain('rpq'); + expect(routeUrl({ mode: 'submit', nav: 'own', query: 'бухгалтер' })).toContain('rpq='); + }); + + it('round-trip запроса через navigate', () => { + navigate({ mode: 'submit', nav: 'search', query: 'Горбенко' }); + expect(parseRoute(window.location.search).query).toBe('Горбенко'); + }); + + it('без rpq подставляется запомненный для ЭТОЙ таблицы запрос', () => { + persistQuery({ mode: 'submit', nav: 'search' }, 'бухгалтер'); + expect(parseRoute('?rpnav=search').query).toBe('бухгалтер'); + }); + + it('запрос запоминается отдельно по таблицам и не переносится между разделами', () => { + persistQuery({ mode: 'submit', nav: 'search' }, 'бухгалтер'); + expect(parseRoute('?rpnav=approvals').query).toBe(''); + }); + + it('подвкладки «Моих заявок» — тоже разные таблицы, у каждой свой запрос', () => { + persistQuery({ mode: 'submit', nav: 'own', ownTab: 'created' }, 'приём'); + persistQuery({ mode: 'submit', nav: 'own', ownTab: 'approved' }, 'место'); + + expect(parseRoute('?rpnav=own&rptab=created').query).toBe('приём'); + expect(parseRoute('?rpnav=own&rptab=approved').query).toBe('место'); + }); + + it('очистка запроса стирает запомненный', () => { + persistQuery({ mode: 'submit', nav: 'search' }, 'бухгалтер'); + expect(parseRoute('?rpnav=search').query).toBe('бухгалтер'); + + persistQuery({ mode: 'submit', nav: 'search' }, ''); + expect(parseRoute('?rpnav=search').query).toBe(''); + }); + + it('storedQueryFor отдаёт запомненный запрос нужной таблицы', () => { + persistQuery({ mode: 'submit', nav: 'approvals' }, 'заявка'); + expect(storedQueryFor('approvals')).toBe('заявка'); + expect(storedQueryFor('execution')).toBe(''); + }); + + it('переход по меню кладёт восстановленный запрос в адрес', () => { + persistQuery({ mode: 'submit', nav: 'approvals' }, 'заявка'); + navigate({ mode: 'submit', nav: 'approvals', query: storedQueryFor('approvals') || undefined }); + expect(parseRoute(window.location.search).query).toBe('заявка'); + }); + + it('replace не добавляет запись в историю — набор текста не засоряет «назад»', () => { + navigate({ mode: 'submit', nav: 'search' }); + const before = window.history.length; + + navigate({ mode: 'submit', nav: 'search', query: 'бух' }, { replace: true }); + navigate({ mode: 'submit', nav: 'search', query: 'бухг' }, { replace: true }); + + expect(window.history.length).toBe(before); + expect(parseRoute(window.location.search).query).toBe('бухг'); + }); + + it('localStorage недоступен — запрос просто пустой, без падения', () => { + 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').query).toBe(''); + } finally { + window.localStorage.getItem = original; + } + }); +}); + describe('persistOwnTab/persistOwnStatus — запоминание подвкладки/фильтра между визитами', () => { it('без явного rptab в адресе подставляется запомненная подвкладка', () => { persistOwnTab('rejected'); @@ -388,6 +476,7 @@ describe('navigate', () => { ownTab: 'created', ownStatus: undefined, page: 1, + query: '', }); }); diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index 6793eac..d073317 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -97,6 +97,10 @@ const OWN_STATUS_STORAGE_KEY = 'rp-own-status'; // уйдя со страницы 3 «Моих заявок» в «На согласовании», где страница всего // одна, пользователь попал бы в пустоту. const PAGE_STORAGE_PREFIX = 'rp-page:'; +// Текст поиска — тоже свой на каждую таблицу. Префикс НЕ меняем: под ним +// поисковые запросы уже лежат (раньше их хранил хук usePersistedQuery), и +// сохранённое у пользователей должно пережить переезд в маршрут. +const QUERY_STORAGE_PREFIX = 'rp-search-query:'; /** * Ключ таблицы для запоминания страницы. Совпадает по смыслу с ListView в @@ -132,6 +136,34 @@ export function storedPageFor(nav: NavSection, ownTab?: OwnTab): number { return readStoredPage(viewKeyOf(nav, tab)) ?? 1; } +function readStoredQuery(viewKey: string): string { + try { + return window.localStorage.getItem(QUERY_STORAGE_PREFIX + viewKey) || ''; + } catch { + return ''; + } +} + +/** Запомненный текст поиска таблицы — по тем же причинам, что и storedPageFor. */ +export function storedQueryFor(nav: NavSection, ownTab?: OwnTab): string { + const tab = nav === 'own' ? ownTab ?? readStoredOwnTab() : undefined; + return readStoredQuery(viewKeyOf(nav, tab)); +} + +/** Запомнить текст поиска текущей таблицы. */ +export function persistQuery(route: IRoute, query: string): void { + const viewKey = viewKeyOf(route.nav, route.ownTab); + try { + if (query) { + window.localStorage.setItem(QUERY_STORAGE_PREFIX + viewKey, query); + } else { + window.localStorage.removeItem(QUERY_STORAGE_PREFIX + viewKey); + } + } catch { + // localStorage недоступен (приватный режим и т.п.) — просто не сохраняем. + } +} + /** * Запомнить страницу текущей таблицы. Ключ выводится из самого маршрута — * вызывающей стороне не нужно знать, как таблицы различаются между собой. @@ -248,22 +280,25 @@ export function parseRoute(search: string = window.location.search): IRoute { const page = Number.isInteger(pageRaw) && pageRaw > 1 ? pageRaw : readStoredPage(viewKeyOf(nav, ownTab)) ?? 1; + // Текст поиска — из адреса, иначе запомненный для этой же таблицы. + const query = params.get('rpq') ?? readStoredQuery(viewKeyOf(nav, ownTab)); + // Открытая заявка (панель поверх раздела) — при полном наборе параметров. // Раздел под панелью сохраняется целиком, вместе с подвкладкой, фильтром и // страницей: закрыв панель, пользователь должен вернуться ровно в тот же // список и на то же место в нём. if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) { - return { mode: 'approve', nav, formKey, itemId, ownTab, ownStatus, page }; + return { mode: 'approve', nav, formKey, itemId, ownTab, ownStatus, page, query }; } // Редактирование собственной незавершённой заявки — та же панель, но с формой. if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) { - return { mode: 'edit', nav, formKey, itemId, ownTab, ownStatus, page }; + return { mode: 'edit', nav, formKey, itemId, ownTab, ownStatus, page, query }; } // Создание производной заявки с предзаполнением из исходной. if (formKey && sourceId && !isNaN(sourceId)) { return { mode: 'create', nav: 'new', formKey, sourceId }; } - return { mode: 'submit', nav, ownTab, ownStatus, page }; + return { mode: 'submit', nav, ownTab, ownStatus, page, query }; } // Собрать query-строку маршрута, сохранив посторонние параметры страницы. @@ -277,6 +312,7 @@ function buildQuery(route: IRoute): string { params.delete('rptab'); params.delete('rpstatus'); params.delete('rppage'); + params.delete('rpq'); if (route.nav !== 'new') { params.set('rpnav', route.nav); @@ -290,6 +326,7 @@ function buildQuery(route: IRoute): string { } // Первая страница — тоже значение по умолчанию (см. выше про чистую ссылку). if (route.page && route.page > 1) params.set('rppage', String(route.page)); + if (route.query) params.set('rpq', route.query); if ((route.mode === 'approve' || route.mode === 'edit') && route.formKey && route.itemId) { params.set('rpmode', route.mode); params.set('rptype', route.formKey); @@ -307,9 +344,19 @@ function buildQuery(route: IRoute): string { * Перейти на другой маршрут без перезагрузки страницы (SPA): обновляет * query-параметры адресной строки через history.pushState и оповещает * useRoute() своим событием (popstate на pushState сам не срабатывает). + * + * replace=true заменяет текущую запись истории вместо добавления новой — + * для набора текста в поиске: иначе каждая буква становилась бы отдельным + * шагом «назад», и чтобы уйти со страницы, пришлось бы стереть весь запрос + * по одному нажатию. */ -export function navigate(route: IRoute): void { - window.history.pushState({}, '', buildQuery(route)); +export function navigate(route: IRoute, options?: { replace?: boolean }): void { + const url = buildQuery(route); + if (options?.replace) { + window.history.replaceState({}, '', url); + } else { + window.history.pushState({}, '', url); + } window.dispatchEvent(new Event(ROUTE_CHANGE_EVENT)); } diff --git a/src/webparts/requestPortal/shared/usePersistedQuery.ts b/src/webparts/requestPortal/shared/usePersistedQuery.ts index 65dff92..9bdc937 100644 --- a/src/webparts/requestPortal/shared/usePersistedQuery.ts +++ b/src/webparts/requestPortal/shared/usePersistedQuery.ts @@ -1,54 +1,5 @@ import * as React from 'react'; -// Текст поиска переживает перезагрузку страницы (localStorage), но не более -// того — это просто «продолжить с того же места», а не синхронизация между -// вкладками/устройствами. key должен быть уникальным на раздел (см. вызовы в -// RequestsTableView/SearchView), иначе значения разных таблиц перепишут друг друга. -function readStored(storageKey: string): string { - try { - return window.localStorage.getItem(storageKey) || ''; - } catch { - return ''; - } -} - -export function usePersistedQuery(key: string): [string, (value: string) => void] { - const storageKey = `rp-search-query:${key}`; - - const [value, setValue] = React.useState(() => readStored(storageKey)); - - // Компонент может продолжать жить, когда key поменялся: RequestsTableView — - // это один и тот же элемент для вкладок «На согласовании» и «Мои заявки», - // меняется только проп kind. Инициализатор useState при этом повторно НЕ - // срабатывает, поэтому без перечитывания текст из одной вкладки оставался бы - // на экране во второй и записывался уже под её ключ — выглядело бы так, будто - // ключ у них общий. - const prevKey = React.useRef(storageKey); - React.useEffect(() => { - if (prevKey.current === storageKey) return; - prevKey.current = storageKey; - setValue(readStored(storageKey)); - }, [storageKey]); - - const setPersisted = React.useCallback( - (next: string): void => { - setValue(next); - try { - if (next) { - window.localStorage.setItem(storageKey, next); - } else { - window.localStorage.removeItem(storageKey); - } - } catch { - // localStorage недоступен (приватный режим и т.п.) — просто не сохраняем. - } - }, - [storageKey] - ); - - return [value, setPersisted]; -} - // Задержка перед тем, как введённый текст уйдёт в запрос. Без неё поиск // срабатывает на КАЖДОЕ нажатие клавиши: «согласование» — это 12 полных циклов // (Search + дочитывание через REST по каждому типу заявок), из которых нужен @@ -74,3 +25,56 @@ export function useDebouncedQuery(value: string, delayMs: number = SEARCH_DEBOUN return debounced; } + +/** + * Связывает поле поиска с маршрутом: в поле текст меняется мгновенно, а в + * маршрут (и, значит, в адрес) уходит только после паузы в наборе. + * + * Раньше текст хранился прямо здесь, в localStorage (usePersistedQuery). Теперь + * источник правды — маршрут: так поиск попадает в ссылку и работает кнопка + * «назад», а запоминание между визитами делает persistQuery в shared/routing.ts. + * + * Неочевидное место — ref `pushed`: в нём лежит текст, на котором поле и + * маршрут в последний раз сошлись. Он нужен в ОБЕ стороны. + * + * Вниз (маршрут → поле): без него собственное же изменение маршрута + * возвращалось бы обратно в поле и затирало текст, набранный за время паузы: + * пользователь печатает «абвг», через 450 мс в маршрут уходит «абв», маршрут + * меняется, и обратная синхронизация вернула бы в поле «абв», съев «г». + * Поэтому поле перечитывается только от ВНЕШНИХ смен (другой раздел, «назад»). + * + * Вверх (поле → маршрут): сравнивать `debounced` нужно именно с `pushed`, а НЕ + * с текущим routeQuery. RequestsTableView — один экземпляр на все таблицы + * («На согласовании», «Мои заявки» и т.д.), и при переключении раздела меняется + * не поле, а routeQuery. Со сравнением по routeQuery это выглядело как «текст + * изменился» — и запрос ПРЕДЫДУЩЕЙ таблицы уезжал в новую (и там же + * сохранялся), из-за чего один и тот же поиск оказывался во всех разделах. + */ +export function useRoutedQuery( + routeQuery: string, + onQueryChange: (query: string) => void +): [string, (value: string) => void] { + const [input, setInput] = React.useState(routeQuery); + const debounced = useDebouncedQuery(input); + const pushed = React.useRef(routeQuery); + + React.useEffect(() => { + if (debounced === pushed.current) return; + pushed.current = debounced; + onQueryChange(debounced); + // onQueryChange пересоздаётся на каждый рендер родителя — в зависимостях + // он гонял бы эффект вхолостую; важен только сам факт смены debounced. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debounced]); + + React.useEffect(() => { + if (routeQuery === pushed.current) return; + // Пришло извне — принимаем как новую точку схождения. Иначе догоняющий + // debounced (в поле уже новый текст) тут же счёл бы себя правкой + // пользователя и отправил бы этот же текст обратно в маршрут. + pushed.current = routeQuery; + setInput(routeQuery); + }, [routeQuery]); + + return [input, setInput]; +} -- 2.34.1 From 742106c9f5f7bfda1a937ef8c33afd661097b793 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 10 Aug 2026 18:24:03 +0500 Subject: [PATCH 13/13] =?UTF-8?q?=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/deploy.yml | 105 ++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 .gitea/workflows/deploy.yml diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..969ade8 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,105 @@ +name: Build & Deploy to SharePoint Online + +# Скопировано из tnsu-approval-route (.gitea/workflows/deploy_frontend.yml). +# Отличия — только в путях и идентификаторах: там SPFx-проект лежит в подпапке +# frontend/, здесь он и есть корень репозитория, поэтому working-directory и +# префиксы frontend/ убраны. +# +# Запуск только вручную (workflow_dispatch): деплой идёт сразу в каталог +# приложений тенанта, то есть на прод для всех сайтов. +on: + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js 22 + uses: actions/setup-node@v3 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: | + npm config set fetch-retries 5 + npm config set fetch-retry-mintimeout 10000 + npm config set fetch-retry-maxtimeout 120000 + for i in 1 2 3; do + npm install --legacy-peer-deps && break + echo "Attempt $i failed, retrying in 20s..." + sleep 20 + done + + # npm run build = heft test --production (сборка + lint + Jest) и следом + # heft package-solution --production, который и создаёт .sppkg. + - name: Build & package SPFx solution + run: npm run build + + - name: Upload .sppkg artifact + uses: actions/upload-artifact@v3 + with: + name: request-portal-sppkg + path: sharepoint/solution/request-portal.sppkg + retention-days: 7 + + - name: Install CLI for Microsoft 365 + run: | + for i in 1 2 3; do + npm install -g @pnp/cli-microsoft365@9 && break + echo "Attempt $i failed, retrying in 20s..." + sleep 20 + done + + - name: Login to Microsoft 365 (certificate) + run: | + m365 login \ + --authType certificate \ + --appId "${{ secrets.SPO_CLIENT_ID }}" \ + --certificateBase64Encoded "${{ secrets.SPO_CERT_BASE64 }}" \ + --thumbprint "${{ secrets.SPO_CERT_THUMBPRINT }}" \ + --tenant "${{ secrets.SPO_TENANT_ID }}" + + # SOLUTION_ID — solution.id из config/package-solution.json; при его смене + # правится и здесь, иначе deploy уйдёт в чужое приложение. + - name: Upload & Deploy to App Catalog + env: + SOLUTION_ID: "8c31266a-712c-4c72-ae04-bca5a6a071c3" + CATALOG_SITE: "https://tnsukz.sharepoint.com/sites/appcatalog" + SPPKG: "sharepoint/solution/request-portal.sppkg" + FILE_NAME: "request-portal.sppkg" + run: | + # Получаем токен через уже аутентифицированный m365 CLI + TOKEN=$(m365 util accesstoken get --resource https://tnsukz.sharepoint.com --output text) + echo "Token length: ${#TOKEN}" + + echo ">>> Uploading to App Catalog" + HTTP=$(curl -s -o /tmp/upload.json -w "%{http_code}" -X POST \ + "${CATALOG_SITE}/_api/web/GetFolderByServerRelativeUrl('/sites/appcatalog/AppCatalog')/Files/add(overwrite=true,url='${FILE_NAME}')" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "X-RequestDigest: 0x0" \ + -H "Accept: application/json;odata=nometadata" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @"${SPPKG}" || echo "000") + echo "Upload HTTP: ${HTTP}" + cat /tmp/upload.json + [ "$HTTP" = "200" ] || [ "$HTTP" = "201" ] || exit 1 + + echo ">>> Deploying app (id: ${SOLUTION_ID})" + HTTP=$(curl -s -o /tmp/deploy.json -w "%{http_code}" -X POST \ + "${CATALOG_SITE}/_api/web/tenantappcatalog/AvailableApps/GetById('${SOLUTION_ID}')/deploy" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "X-RequestDigest: 0x0" \ + -H "Accept: application/json;odata=nometadata" \ + -H "Content-Type: application/json" \ + -d '{"skipFeatureDeployment":true}' || echo "000") + echo "Deploy HTTP: ${HTTP}" + cat /tmp/deploy.json + [ "$HTTP" = "200" ] || [ "$HTTP" = "204" ] || exit 1 + + echo ">>> Done" -- 2.34.1