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);