From 98be3980cbc5293a63e670a1ca87c761e7728ce1 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 25 Sep 2026 11:48:09 +0000 Subject: [PATCH] Feature/request type access all requests (#22) Co-authored-by: gorbenko.pavel Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/22 --- .../RequestGrid/RequestGrid.module.scss | 28 +++++ .../components/RequestGrid/RequestGrid.tsx | 71 +++++++----- .../components/RequestPortal.tsx | 85 ++++++++++++++- .../myRequests/AllRequestsFeature.module.scss | 14 +++ .../myRequests/AllRequestsFeature.tsx | 103 ++++++++++++++++++ .../features/myRequests/RequestsTableView.tsx | 27 ++++- .../features/myRequests/models/IMyRequest.ts | 10 +- .../features/myRequests/myRequests.service.ts | 47 +++++++- .../features/submit/SubmitFeature.module.scss | 26 +++++ .../features/submit/SubmitFeature.tsx | 64 ++++++++--- .../shared/approval/approvalRoute.service.ts | 22 ++++ .../requestPortal/shared/approval/index.ts | 3 + .../shared/approval/requestTypeAccess.ts | 48 ++++++++ .../useAccessibleAllRequestsFormKeys.ts | 35 ++++++ src/webparts/requestPortal/shared/routing.ts | 63 ++++++++++- 15 files changed, 590 insertions(+), 56 deletions(-) create mode 100644 src/webparts/requestPortal/features/myRequests/AllRequestsFeature.module.scss create mode 100644 src/webparts/requestPortal/features/myRequests/AllRequestsFeature.tsx create mode 100644 src/webparts/requestPortal/shared/approval/requestTypeAccess.ts create mode 100644 src/webparts/requestPortal/shared/approval/useAccessibleAllRequestsFormKeys.ts diff --git a/src/webparts/requestPortal/components/RequestGrid/RequestGrid.module.scss b/src/webparts/requestPortal/components/RequestGrid/RequestGrid.module.scss index ac7dfee..edf2a0c 100644 --- a/src/webparts/requestPortal/components/RequestGrid/RequestGrid.module.scss +++ b/src/webparts/requestPortal/components/RequestGrid/RequestGrid.module.scss @@ -6,6 +6,34 @@ gap: 14px; } +.tileWrap { + position: relative; +} + +.allRequestsButton { + position: absolute; + top: 10px; + right: 10px; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid $gray-200; + border-radius: $r-md; + background: $white; + color: $gray-400; + cursor: pointer; + transition: color .18s, border-color .18s; + + &:hover { + color: $gold-600; + border-color: $gold-400; + } +} + .tile { position: relative; display: flex; diff --git a/src/webparts/requestPortal/components/RequestGrid/RequestGrid.tsx b/src/webparts/requestPortal/components/RequestGrid/RequestGrid.tsx index d188d78..fa4cf04 100644 --- a/src/webparts/requestPortal/components/RequestGrid/RequestGrid.tsx +++ b/src/webparts/requestPortal/components/RequestGrid/RequestGrid.tsx @@ -7,9 +7,18 @@ import { useIsTester } from '../../shared/useIsTester'; interface IRequestGridProps { requests: IRequestType[]; onSelect: (request: IRequestType) => void; + /** pageUrl → RequestTypeId, только для типов с доступом к «Все заявки» (см. useAccessibleAllRequestsFormKeys). */ + accessibleAllRequestsIds?: Map; + /** Клик по кнопке «Все заявки» на плитке — задан вместе с accessibleAllRequestsIds. */ + onOpenAllRequests?: (requestTypeId: number) => void; } -export const RequestGrid: React.FC = ({ requests, onSelect }) => { +export const RequestGrid: React.FC = ({ + requests, + onSelect, + accessibleAllRequestsIds, + onOpenAllRequests, +}) => { // Группа «Тестирование» видит формы «в разработке» открытыми — чтобы проверять // их до запуска для всех (см. shared/useIsTester). const isTester = useIsTester() === true; @@ -22,33 +31,45 @@ export const RequestGrid: React.FC = ({ requests, onSelect })
{requests.map((request) => { const isLocked = !!request.comingSoon && !isTester; + const allRequestsTypeId = accessibleAllRequestsIds?.get(request.id); return ( - + {allRequestsTypeId !== undefined && onOpenAllRequests && ( + )} - +
); })} diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 54995fc..2cfdb51 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -7,11 +7,13 @@ import type { ISidebarItem } from '@tnsu/ui-kit'; import { ConfirmHost } from '../shared/ui'; import { navigate, + persistAllTypeId, persistNav, persistOwnTab, persistOwnStatus, persistPage, persistQuery, + storedAllTypeId, storedOwnStatus, storedOwnTab, storedPageFor, @@ -34,11 +36,13 @@ import EditFeature from '../features/edit/EditFeature'; import { ListView, RequestsTableView } from '../features/myRequests/RequestsTableView'; import { OwnTabs } from '../features/myRequests/OwnTabs'; import { SearchView } from '../features/myRequests/SearchView'; -import { getNavCounts, INavCounts } from '../features/myRequests/myRequests.service'; +import { getLinkedTypes, getNavCounts, INavCounts } from '../features/myRequests/myRequests.service'; import { IMyRequest } from '../features/myRequests/models/IMyRequest'; import { isEditableStatus } from '../shared/approval'; import { hasReportsAccess } from '../shared/reports/reportsAccess'; import { ReportsFeature } from '../features/reports/ReportsFeature'; +import { AllRequestsFeature } from '../features/myRequests/AllRequestsFeature'; +import { accessibleAllRequestsTypes } from '../shared/approval'; // Оболочка портала: тёмное боковое меню (разделы) + основная область. // Логики фич тут нет — файл общий, меняется редко. @@ -102,6 +106,33 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context } }, [route.nav, canSeeReports]); + // Раздел «Все заявки» — виден только тем, кому в админке approval-route + // выдан RequestTypeAccess хотя бы на один тип (см. shared/approval/ + // requestTypeAccess.ts). undefined — ответ ещё не пришёл. + const [canSeeAllRequests, setCanSeeAllRequests] = React.useState(undefined); + React.useEffect(() => { + let cancelled = false; + setCanSeeAllRequests(undefined); + getLinkedTypes(context) + .then((types) => accessibleAllRequestsTypes(context, types)) + .then((accessible) => { + if (!cancelled) setCanSeeAllRequests(accessible.length > 0); + }) + .catch(() => { + if (!cancelled) setCanSeeAllRequests(false); + }); + return () => { + cancelled = true; + }; + }, [context, impersonatedEmail]); + + // Та же защита от прямой ссылки, что и у «Отчётов» выше. + React.useEffect(() => { + if (route.nav === 'allRequests' && canSeeAllRequests === false) { + navigate({ mode: 'submit', nav: 'new' }, { replace: true }); + } + }, [route.nav, canSeeAllRequests]); + // Уникальный код заявки для заголовка Drawer (approve/edit) — сама форма // узнаёт его только после загрузки (см. onCodeLoaded в ApproveFeature/ // EditFeature), поэтому не берём route.itemId (это RowID в SharePoint, а не @@ -201,6 +232,19 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context }); return; } + // Тип заявки в «Все заявки» — из запомненного (см. gotoAllRequestsType + // ниже, откуда он и запоминается). Не задан ни разу — undefined, раздел + // сам выберет первый доступный тип (см. AllRequestsFeature). + if (nav === 'allRequests') { + navigate({ + mode: 'submit', + nav, + allTypeId: storedAllTypeId(), + page: storedPageFor(nav), + query: storedQueryFor(nav) || undefined, + }); + return; + } navigate({ mode: 'submit', nav, @@ -224,6 +268,28 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context }); }; + // Переход в «Все заявки» — либо с бокового меню (typeId не задан, раздел сам + // выберет тип), либо с кнопки формы (typeId уже известен, см. + // gotoAllRequestsForType и SubmitFeature.onOpenAllRequests), либо смена типа + // в самом разделе (пикер в AllRequestsFeature). Запоминаем выбор — как и + // persistOwnTab, только при явном выборе (в т.ч. том, что раздел сделал + // сам по умолчанию), чтобы следующий заход открыл тот же тип. + const gotoAllRequestsType = (typeId: number): void => { + persistAllTypeId(typeId); + navigate({ + mode: 'submit', + nav: 'allRequests', + allTypeId: typeId, + page: storedPageFor('allRequests'), + query: storedQueryFor('allRequests') || undefined, + }); + }; + + const gotoAllRequestsForType = (typeId: number): void => { + persistNav('allRequests'); + gotoAllRequestsType(typeId); + }; + const setOwnStatus = (ownStatus: string): void => { const normalized = ownStatus || undefined; persistOwnStatus(normalized); @@ -281,6 +347,7 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context listId: request.listId, ownTab: route.ownTab, ownStatus: route.ownStatus, + allTypeId: route.allTypeId, // Страница и запрос — часть того же списка под панелью: без них ссылка на // открытую заявку вела бы на первую страницу без поиска. page: route.page, @@ -295,6 +362,7 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context nav: route.nav, ownTab: route.ownTab, ownStatus: route.ownStatus, + allTypeId: route.allTypeId, page: route.page, query: route.query, }); @@ -324,6 +392,7 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context { id: 'rework', title: 'Доработка', icon: 'edit', count: navCounts?.rework }, { id: 'own', title: 'Мои заявки', icon: 'clipboard' }, { id: 'search', title: 'Поиск', icon: 'search' }, + ...(canSeeAllRequests ? [{ id: 'allRequests', title: 'Все заявки', icon: 'people' } as ISidebarItem] : []), ...(canSeeReports ? [{ id: 'reports', title: 'Отчёты', icon: 'chart' } as ISidebarItem] : []), { id: 'instructions', title: 'Инструкция пользователя', icon: 'info' }, ]; @@ -413,6 +482,7 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context : undefined } onFormSelected={(formKey) => navigate({ mode: 'create', nav: 'new', formKey })} + onOpenAllRequests={gotoAllRequestsForType} /> @@ -433,6 +503,19 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context {route.nav === 'reports' && canSeeReports && } + {route.nav === 'allRequests' && canSeeAllRequests && ( + + )} + {LIST_VIEWS[route.nav] !== undefined && ( void; + page: number; + onPageChange: (page: number) => void; + query: string; + onQueryChange: (query: string) => void; + onOpen: (request: IMyRequest) => void; +} + +/** + * Раздел «Все заявки»: доступен только тем, у кого в админке approval-route + * настроен RequestTypeAccess хотя бы на один тип заявки (см. + * shared/approval/requestTypeAccess.ts). Показывает заявки ВСЕХ авторов + * выбранного типа — в отличие от «Моих заявок», где отбор всегда идёт по + * текущему пользователю. + */ +export const AllRequestsFeature: React.FC = ({ + context, + selectedTypeId, + onTypeChange, + page, + onPageChange, + query, + onQueryChange, + onOpen, +}) => { + const [types, setTypes] = React.useState(undefined); + const [error, setError] = React.useState(''); + + React.useEffect(() => { + let cancelled = false; + getLinkedTypes(context) + .then((all) => accessibleAllRequestsTypes(context, all)) + .then((accessible) => { + if (!cancelled) setTypes(accessible); + }) + .catch((e) => { + if (!cancelled) setError((e as Error).message); + }); + return () => { + cancelled = true; + }; + }, [context]); + + // Тип по умолчанию — из адреса, если он всё ещё доступен, иначе первый + // доступный. Отдельным эффектом (а не при рендере): смена выбора идёт через + // navigate() у вызывающей стороны, и до его отработки selectedTypeId ещё + // хранит предыдущее значение. + React.useEffect(() => { + if (!types || types.length === 0) return; + const stillValid = !!selectedTypeId && types.some((t) => t.id === selectedTypeId); + if (!stillValid) onTypeChange(types[0].id); + // eslint-disable-next-line react-hooks/exhaustive-deps -- onTypeChange стабилен по смыслу (см. gotoAllRequestsType в RequestPortal), включение его в deps дало бы лишний повторный запуск на каждый рендер + }, [types, selectedTypeId]); + + if (error) { + return Не удалось загрузить типы заявок: {error}; + } + if (types === undefined) { + return
Загрузка…
; + } + if (types.length === 0) { + return
Вам не выдан доступ ни к одному типу заявок.
; + } + + const selectedType = types.find((t) => t.id === selectedTypeId) ?? types[0]; + + return ( +
+ {types.length > 1 && ( +
+