From 79e44e01be70611b73923073de94437868ddd2e6 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 4 Aug 2026 12:09:47 +0500 Subject: [PATCH] =?UTF-8?q?=D1=82=D0=B5=D0=BF=D0=B5=D1=80=D1=8C=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B6=D0=BD=D0=BE=20=D0=BF=D1=80=D0=BE=D0=B9=D1=82=D0=B8?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D1=83=20=D0=B2?= =?UTF-8?q?=20=D0=BC=D0=BE=D0=B8=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RequestGrid/RequestGrid.module.scss | 2 - .../components/RequestPortal.module.scss | 24 ++++++ .../components/RequestPortal.tsx | 74 ++++++++++++++----- .../myRequests/MyRequestsPanel.module.scss | 7 ++ .../features/myRequests/MyRequestsPanel.tsx | 36 ++++++--- .../features/myRequests/models/IMyRequest.ts | 2 + .../features/myRequests/myRequests.service.ts | 1 + .../features/submit/SubmitFeature.module.scss | 2 - .../features/submit/SubmitFeature.tsx | 30 +++----- src/webparts/requestPortal/shared/routing.ts | 74 +++++++++++++++++++ 10 files changed, 199 insertions(+), 53 deletions(-) diff --git a/src/webparts/requestPortal/components/RequestGrid/RequestGrid.module.scss b/src/webparts/requestPortal/components/RequestGrid/RequestGrid.module.scss index 344b48d..e11202b 100644 --- a/src/webparts/requestPortal/components/RequestGrid/RequestGrid.module.scss +++ b/src/webparts/requestPortal/components/RequestGrid/RequestGrid.module.scss @@ -4,7 +4,6 @@ display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); gap: 14px; - margin-top: $sp-6; } .tile { @@ -99,7 +98,6 @@ } .empty { - margin-top: $sp-6; color: $gray-500; font-size: $fs-sm; } diff --git a/src/webparts/requestPortal/components/RequestPortal.module.scss b/src/webparts/requestPortal/components/RequestPortal.module.scss index eba25ac..646caaa 100644 --- a/src/webparts/requestPortal/components/RequestPortal.module.scss +++ b/src/webparts/requestPortal/components/RequestPortal.module.scss @@ -54,6 +54,30 @@ align-self: stretch; } +.tabsWrap { + margin-bottom: $sp-6; +} + +// Возврат из режима согласования (approve) к списку заявок (SubmitFeature). +.backButton { + display: inline-flex; + align-items: center; + gap: $sp-2; + margin-bottom: $sp-4; + padding: 0; + border: 0; + background: transparent; + color: $navy-700; + font-family: $font-sans; + font-size: $fs-sm; + cursor: pointer; + transition: color .18s; + + &:hover { + color: $gold-600; + } +} + // На узких экранах панель уходит под формы. @media (max-width: 900px) { .layout { diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 4923a2f..99d9aea 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -2,42 +2,76 @@ import * as React from 'react'; import styles from './RequestPortal.module.scss'; import type { IRequestPortalProps } from './IRequestPortalProps'; import { IconSprite } from '../shared/ui/Icon/IconSprite'; -import { parseRoute } from '../shared/routing'; +import { Icon } from '../shared/ui/Icon/Icon'; +import { navigate, useRoute } from '../shared/routing'; +import { CategoryTabs } from './CategoryTabs/CategoryTabs'; +import { CATEGORIES, DEFAULT_CATEGORY_ID } from '../data/categories'; +import { REQUEST_TYPES } from '../data/requests'; import SubmitFeature from '../features/submit/SubmitFeature'; import ApproveFeature from '../features/approve/ApproveFeature'; import MyRequestsPanel from '../features/myRequests/MyRequestsPanel'; // Оболочка портала. Логики фич тут нет — файл общий, меняется редко. // Роутинг по URL: mode=approve&type=&id= → фича согласования, иначе — подача. +// +// Вкладки категорий рендерятся здесь (а не внутри SubmitFeature), чтобы +// оставаться на экране и в режиме согласования (approve) — раньше режим +// согласования подменял всю область main и вкладки пропадали вместе с ней. const RequestPortal: React.FC = ({ hasTeamsContext, context }) => { const rootClasses = [styles.requestPortal, hasTeamsContext ? styles.teams : ''] .filter(Boolean) .join(' '); - const route = parseRoute(); + const route = useRoute(); + + const [activeCategoryId, setActiveCategoryId] = React.useState(() => { + if (route.mode === 'create' && route.formKey) { + const found = REQUEST_TYPES.filter((r) => r.id === route.formKey)[0]; + if (found) return found.categoryId; + } + return DEFAULT_CATEGORY_ID; + }); return (
- {route.mode === 'approve' && route.formKey && route.itemId ? ( - - ) : ( -
-
- -
-
- -
+
+
+
- )} +
+
+ +
+ {route.mode === 'approve' && route.formKey && route.itemId ? ( + <> + + + + ) : ( + + )} +
+
); }; diff --git a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss index a577f1f..2edac8e 100644 --- a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss +++ b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss @@ -70,6 +70,13 @@ border: 1px solid $gray-200; border-radius: $r-sm; background: $gray-50; + text-decoration: none; + transition: .16s; + + &:hover { + border-color: $gold-400; + background: $gold-50; + } } .itemMain { diff --git a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx index 1c42263..0413c0f 100644 --- a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx +++ b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx @@ -3,6 +3,7 @@ import type { WebPartContext } from '@microsoft/sp-webpart-base'; import { getMyRequests } from './myRequests.service'; import { IMyRequest } from './models/IMyRequest'; import { REQUEST_STATUS } from '../../shared/approval'; +import { navigate, routeUrl } from '../../shared/routing'; import styles from './MyRequestsPanel.module.scss'; export interface IMyRequestsPanelProps { @@ -97,17 +98,32 @@ const MyRequestsPanel: React.FC = ({ context }) => {
{group.requestTypeName}
diff --git a/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts index 40e1acc..06520fd 100644 --- a/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts +++ b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts @@ -11,4 +11,6 @@ export interface IMyRequest { requestTypeId: number; requestTypeName: string; section: string; + /** Ключ формы (rptype в URL режима согласования) — поле pageUrl из approval-route API. */ + formKey: string; } diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index e55361a..0c6059e 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -86,6 +86,7 @@ async function getMyRequestsForType( requestTypeId: type.id, requestTypeName: type.name, section: type.section, + formKey: type.pageUrl, }; }) .filter((r) => !!tokenEmail && r.authorEmail.toLowerCase() === tokenEmail); diff --git a/src/webparts/requestPortal/features/submit/SubmitFeature.module.scss b/src/webparts/requestPortal/features/submit/SubmitFeature.module.scss index 3210d2a..676670a 100644 --- a/src/webparts/requestPortal/features/submit/SubmitFeature.module.scss +++ b/src/webparts/requestPortal/features/submit/SubmitFeature.module.scss @@ -10,7 +10,6 @@ // ── Заглушка формы (до появления реальных форм) ── .formPlaceholder { - margin-top: $sp-6; padding: $sp-8; background: $white; border: 1px solid $gray-200; @@ -44,7 +43,6 @@ } .loading { - margin-top: $sp-6; padding: $sp-8; color: $gray-500; font-size: $fs-sm; diff --git a/src/webparts/requestPortal/features/submit/SubmitFeature.tsx b/src/webparts/requestPortal/features/submit/SubmitFeature.tsx index c915c37..62ae41c 100644 --- a/src/webparts/requestPortal/features/submit/SubmitFeature.tsx +++ b/src/webparts/requestPortal/features/submit/SubmitFeature.tsx @@ -1,41 +1,39 @@ import * as React from 'react'; -import { useState, useMemo } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import type { WebPartContext } from '@microsoft/sp-webpart-base'; import styles from './SubmitFeature.module.scss'; import { Icon } from '../../shared/ui/Icon/Icon'; -import { CategoryTabs } from '../../components/CategoryTabs/CategoryTabs'; import { RequestGrid } from '../../components/RequestGrid/RequestGrid'; -import { CATEGORIES, DEFAULT_CATEGORY_ID } from '../../data/categories'; import { getRequestsByCategory, REQUEST_TYPES } from '../../data/requests'; import { getFormComponent } from '../../forms/registry'; import { IRequestType } from '../../models/types'; export interface ISubmitFeatureProps { context: WebPartContext; + /** Активная категория. Вкладки теперь рендерятся на уровне портала (RequestPortal), + * чтобы оставаться на экране и в режиме согласования — эта фича их только слушает. */ + activeCategoryId: string; /** Открыть сразу форму с предзаполнением (производная заявка), минуя плитки. */ initialForm?: { formKey: string; sourceId: number }; } -// Фича «Подать заявку»: категории → плитки → беспоук-форма. +// Фича «Подать заявку»: плитки → беспоук-форма (категории/вкладки — в RequestPortal). // Владелец: автор портала. Живёт целиком в features/submit/**. -const SubmitFeature: React.FC = ({ context, initialForm }) => { +const SubmitFeature: React.FC = ({ context, activeCategoryId, initialForm }) => { // Если пришли по ссылке «создать на основе» — сразу открываем нужную форму. const initialSelected = useMemo( () => (initialForm ? REQUEST_TYPES.filter((r) => r.id === initialForm.formKey)[0] ?? null : null), [initialForm] ); - const [activeCategoryId, setActiveCategoryId] = useState( - initialSelected ? initialSelected.categoryId : DEFAULT_CATEGORY_ID - ); const [selectedRequest, setSelectedRequest] = useState(initialSelected); - const requests = getRequestsByCategory(activeCategoryId); + // Смена категории (вкладка сверху) — возвращаемся к списку плиток. + useEffect(() => { + setSelectedRequest((prev) => (prev && prev.categoryId !== activeCategoryId ? null : prev)); + }, [activeCategoryId]); - const handleCategoryChange = (categoryId: string): void => { - setActiveCategoryId(categoryId); - setSelectedRequest(null); // при смене категории возвращаемся к списку плиток - }; + const requests = getRequestsByCategory(activeCategoryId); const handleBack = (): void => setSelectedRequest(null); @@ -69,12 +67,6 @@ const SubmitFeature: React.FC = ({ context, initialForm }) return (
- - {selectedRequest ? ( renderSelected(selectedRequest) ) : ( diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index 389e813..951ba0c 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -1,3 +1,5 @@ +import * as React from 'react'; + // Маршрутизация портала по параметрам URL. // // Обычный режим (нет параметров) — подача заявок (features/submit). @@ -8,6 +10,11 @@ // Префикс rp (request-portal): имена id/mode/type зарезервированы SharePoint // (напр. ?id= он трактует как навигацию к элементу → «Элемент не существует»), // поэтому используем собственные, неконфликтные имена параметров. +// +// Навигация внутри портала (напр. клик по заявке в «Мои заявки») идёт через +// navigate() — это history.pushState без перезагрузки страницы. useRoute() +// подписан и на popstate (кнопка «назад»), и на её собственное событие, +// поэтому реагирует на оба способа смены URL. export type PortalMode = 'submit' | 'approve' | 'create'; @@ -21,6 +28,8 @@ export interface IRoute { sourceId?: number; } +const ROUTE_CHANGE_EVENT = 'rp-route-change'; + /** Разобрать строку запроса (по умолчанию — из адреса страницы) в маршрут. */ export function parseRoute(search: string = window.location.search): IRoute { const params = new URLSearchParams(search); @@ -41,3 +50,68 @@ export function parseRoute(search: string = window.location.search): IRoute { } return { mode: 'submit' }; } + +/** + * Перейти на другой маршрут без перезагрузки страницы (SPA): обновляет + * query-параметры адресной строки через history.pushState и оповещает + * useRoute() своим событием (popstate на pushState сам не срабатывает). + */ +export function navigate(route: IRoute): void { + const params = new URLSearchParams(window.location.search); + params.delete('rpmode'); + params.delete('rptype'); + params.delete('rpid'); + params.delete('rpsource'); + + if (route.mode === 'approve' && route.formKey && route.itemId) { + params.set('rpmode', 'approve'); + params.set('rptype', route.formKey); + params.set('rpid', String(route.itemId)); + } else if (route.mode === 'create' && route.formKey && route.sourceId) { + params.set('rptype', route.formKey); + params.set('rpsource', String(route.sourceId)); + } + + const query = params.toString(); + const url = `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`; + window.history.pushState({}, '', url); + window.dispatchEvent(new Event(ROUTE_CHANGE_EVENT)); +} + +/** Ссылка на маршрут (для href — открытие в новой вкладке, копирование и т.п.). */ +export function routeUrl(route: IRoute): string { + const params = new URLSearchParams(window.location.search); + params.delete('rpmode'); + params.delete('rptype'); + params.delete('rpid'); + params.delete('rpsource'); + + if (route.mode === 'approve' && route.formKey && route.itemId) { + params.set('rpmode', 'approve'); + params.set('rptype', route.formKey); + params.set('rpid', String(route.itemId)); + } else if (route.mode === 'create' && route.formKey && route.sourceId) { + params.set('rptype', route.formKey); + params.set('rpsource', String(route.sourceId)); + } + + const query = params.toString(); + return `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`; +} + +/** Текущий маршрут, реактивно обновляется по navigate() и кнопке «назад». */ +export function useRoute(): IRoute { + const [route, setRoute] = React.useState(() => parseRoute()); + + React.useEffect(() => { + const onChange = (): void => setRoute(parseRoute()); + window.addEventListener('popstate', onChange); + window.addEventListener(ROUTE_CHANGE_EVENT, onChange); + return () => { + window.removeEventListener('popstate', onChange); + window.removeEventListener(ROUTE_CHANGE_EVENT, onChange); + }; + }, []); + + return route; +}