From 0aa0a60e40c41a02dcfdc19ca40bd213ec8fe3e8 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 4 Aug 2026 12:02:11 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20=D1=81?= =?UTF-8?q?=D0=BE=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0=D0=BC=D0=B8=20?= =?UTF-8?q?=D0=B7=D0=B0=D1=8F=D0=B2=D0=BE=D0=BA=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: gorbenko.pavel Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/1 Co-authored-by: gorbenko.pavel Co-committed-by: gorbenko.pavel --- .gitignore | 3 + .../components/Drawer/Drawer.module.scss | 83 +++++ .../components/Drawer/Drawer.tsx | 39 +++ .../RequestGrid/RequestGrid.module.scss | 2 - .../components/RequestPortal.module.scss | 18 +- .../components/RequestPortal.tsx | 126 ++++++-- .../components/SideNav/SideNav.module.scss | 119 +++++++ .../components/SideNav/SideNav.tsx | 75 +++++ .../myRequests/MyRequestsPanel.module.scss | 33 -- .../features/myRequests/MyRequestsPanel.tsx | 26 -- .../myRequests/RequestsTableView.module.scss | 244 ++++++++++++++ .../features/myRequests/RequestsTableView.tsx | 283 ++++++++++++++++ .../features/myRequests/models/IMyRequest.ts | 18 ++ .../myRequests/models/IMyRequestsDebug.ts | 24 ++ .../features/myRequests/myRequests.service.ts | 306 ++++++++++++++++++ .../features/submit/SubmitFeature.module.scss | 9 +- .../features/submit/SubmitFeature.tsx | 39 ++- .../approval/approverDecision.service.ts | 2 + .../approval/models/IApprovalRouteApi.ts | 5 + .../requestPortal/shared/requestEvents.ts | 15 + src/webparts/requestPortal/shared/routing.ts | 94 +++++- 21 files changed, 1442 insertions(+), 121 deletions(-) create mode 100644 src/webparts/requestPortal/components/Drawer/Drawer.module.scss create mode 100644 src/webparts/requestPortal/components/Drawer/Drawer.tsx create mode 100644 src/webparts/requestPortal/components/SideNav/SideNav.module.scss create mode 100644 src/webparts/requestPortal/components/SideNav/SideNav.tsx delete mode 100644 src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss delete mode 100644 src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx create mode 100644 src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss create mode 100644 src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx create mode 100644 src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts create mode 100644 src/webparts/requestPortal/features/myRequests/models/IMyRequestsDebug.ts create mode 100644 src/webparts/requestPortal/features/myRequests/myRequests.service.ts create mode 100644 src/webparts/requestPortal/shared/requestEvents.ts diff --git a/.gitignore b/.gitignore index f9e08d6..b900fc7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ jest-output/ *.sppkg .heft/ sharepoint/solution/ + +# JetBrains IDEA +.idea/ diff --git a/src/webparts/requestPortal/components/Drawer/Drawer.module.scss b/src/webparts/requestPortal/components/Drawer/Drawer.module.scss new file mode 100644 index 0000000..828ed4a --- /dev/null +++ b/src/webparts/requestPortal/components/Drawer/Drawer.module.scss @@ -0,0 +1,83 @@ +@import '../../../../styles/design-tokens'; + +// Выезжающая панель поверх страницы. position: fixed — панель должна перекрывать +// всю страницу SharePoint, а не только канву web part. +.overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + justify-content: flex-end; +} + +.backdrop { + position: absolute; + inset: 0; + background: rgba(10, 27, 48, .45); + animation: fade .2s ease-out; +} + +.drawer { + position: relative; + display: flex; + flex-direction: column; + width: min(920px, 100%); + height: 100%; + background: $gray-50; + box-shadow: $sh-modal; + animation: slide .22s ease-out; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: $sp-4; + flex-shrink: 0; + padding: $sp-4 $sp-6; + background: $white; + border-bottom: 1px solid $gray-200; +} + +.title { + font-family: $font-serif; + font-size: 18px; + color: $navy-900; +} + +.close { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + border: 1px solid $gray-300; + border-radius: $r-sm; + background: $white; + color: $navy-700; + cursor: pointer; + transition: .16s; + + &:hover { + border-color: $red-500; + color: $red-500; + } +} + +.body { + flex: 1; + overflow-y: auto; + padding: $sp-6; +} + +@keyframes slide { + from { transform: translateX(24px); opacity: .6; } + to { transform: translateX(0); opacity: 1; } +} + +@keyframes fade { + from { opacity: 0; } + to { opacity: 1; } +} diff --git a/src/webparts/requestPortal/components/Drawer/Drawer.tsx b/src/webparts/requestPortal/components/Drawer/Drawer.tsx new file mode 100644 index 0000000..7406628 --- /dev/null +++ b/src/webparts/requestPortal/components/Drawer/Drawer.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import styles from './Drawer.module.scss'; +import { Icon } from '../../shared/ui/Icon/Icon'; + +interface IDrawerProps { + title: string; + onClose: () => void; + children: React.ReactNode; +} + +// Выезжающая справа панель: открывается по клику на строку таблицы заявок, +// внутри — заявка в режиме просмотра/согласования. Закрывается крестиком, +// кликом по затемнению и клавишей Esc. +export const Drawer: React.FC = ({ title, onClose, children }) => { + React.useEffect(() => { + const onKeyDown = (e: KeyboardEvent): void => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [onClose]); + + return ( +
+ {/* Затемнение — не кнопка (внутри панели свои интерактивные элементы), + поэтому клик обрабатываем на этом слое напрямую. */} + + ); +}; 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..bd3c4ad 100644 --- a/src/webparts/requestPortal/components/RequestPortal.module.scss +++ b/src/webparts/requestPortal/components/RequestPortal.module.scss @@ -32,13 +32,13 @@ font-family: $font-sans; } -// Двухколоночная раскладка: формы (main) + постоянная панель (side). +// Раскладка: боковое меню (side) + основная область (main). .layout { display: flex; align-items: flex-start; gap: $sp-6; width: 100%; - max-width: 1400px; + max-width: 1600px; margin: 0 auto; } @@ -47,21 +47,27 @@ min-width: 0; // чтобы контент не распирал колонку } +// Ширину задаёт само меню (SideNav сворачивается) — колонка просто под него. .side { - flex: 0 0 380px; + flex: 0 0 auto; position: sticky; top: $sp-4; - align-self: stretch; } -// На узких экранах панель уходит под формы. +// Та же ширина, что у контента подачи (SubmitFeature .content) — чтобы вкладки +// не уезжали шире формы под ними. +.tabsWrap { + max-width: 1080px; + margin-bottom: $sp-6; +} + +// На узких экранах меню уходит наверх, в одну строку. @media (max-width: 900px) { .layout { flex-direction: column; } .side { - flex-basis: auto; width: 100%; position: static; } diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 6e6cf00..0855574 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -2,41 +2,123 @@ 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 { navigate, useRoute, NavSection } from '../shared/routing'; +import { onRequestsChanged } from '../shared/requestEvents'; +import { SideNav } from './SideNav/SideNav'; +import { Drawer } from './Drawer/Drawer'; +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 { RequestsTableView } from '../features/myRequests/RequestsTableView'; +import { getPendingApproval } from '../features/myRequests/myRequests.service'; -// Оболочка портала. Логики фич тут нет — файл общий, меняется редко. -// Роутинг по URL: mode=approve&type=&id= → фича согласования, иначе — подача. +// Оболочка портала: тёмное боковое меню (разделы) + основная область. +// Логики фич тут нет — файл общий, меняется редко. // -// Панель «Мои заявки» (features/myRequests) готова как компонент, но НЕ смонтирована — -// её подключает второй разработчик, когда наполнит. Для этого: -// 1) import MyRequestsPanel from '../features/myRequests/MyRequestsPanel'; -// 2) обернуть контент подачи в
с двумя колонками: -//
-//
-// (классы layout/main/side уже есть в RequestPortal.module.scss) +// Разделы (rpnav): 'new' — подача заявки (вкладки категорий + плитки + форма), +// 'approvals'/'own' — таблицы заявок. Клик по строке таблицы открывает заявку +// в выезжающей панели ПОВЕРХ раздела (rpmode=approve&rptype=&rpid=), поэтому +// раздел под ней сохраняется; та же ссылка приходит из потока Power Automate. const RequestPortal: React.FC = ({ hasTeamsContext, context }) => { const rootClasses = [styles.requestPortal, hasTeamsContext ? styles.teams : ''] .filter(Boolean) .join(' '); - const route = parseRoute(); + const route = useRoute(); + const [navCollapsed, setNavCollapsed] = React.useState(false); + const [approvalsCount, setApprovalsCount] = React.useState(undefined); + + 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; + }); + + // Счётчик на пункте «На согласовании». Обновляется по тому же событию, что и + // сами таблицы (создание заявки, решение согласующего). + React.useEffect(() => { + let cancelled = false; + + const load = (): void => { + getPendingApproval(context) + .then((r) => { + if (!cancelled) setApprovalsCount(r.rows.length); + }) + .catch(() => { + if (!cancelled) setApprovalsCount(undefined); + }); + }; + + load(); + const unsubscribe = onRequestsChanged(load); + return () => { + cancelled = true; + unsubscribe(); + }; + }, [context]); + + const goto = (nav: NavSection): void => navigate({ mode: 'submit', nav }); + const closeDrawer = (): void => navigate({ mode: 'submit', nav: route.nav }); return (
- {route.mode === 'approve' && route.formKey && route.itemId ? ( - - ) : ( - +
+
+ setNavCollapsed((v) => !v)} + /> +
+ +
+ {route.nav === 'new' ? ( + <> +
+ +
+ + + ) : ( + + navigate({ + mode: 'approve', + nav: route.nav, + formKey: request.formKey, + itemId: request.id, + }) + } + /> + )} +
+
+ + {route.mode === 'approve' && route.formKey && route.itemId && ( + + + )}
); diff --git a/src/webparts/requestPortal/components/SideNav/SideNav.module.scss b/src/webparts/requestPortal/components/SideNav/SideNav.module.scss new file mode 100644 index 0000000..8ea413b --- /dev/null +++ b/src/webparts/requestPortal/components/SideNav/SideNav.module.scss @@ -0,0 +1,119 @@ +@import '../../../../styles/design-tokens'; + +// Тёмное боковое меню портала (фирменный navy-градиент, золотой маркер +// активного пункта — как в остальном дизайне). +.nav { + display: flex; + flex-direction: column; + gap: 2px; + width: 240px; + padding: $sp-3 0; + background: $grad-header; + border-radius: $r-lg; + box-shadow: $sh-2; + overflow: hidden; + transition: width .2s; +} + +.toggle { + align-self: flex-end; + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + margin: 0 $sp-3 $sp-2; + padding: 0; + border: 1px solid rgba(255, 255, 255, .18); + border-radius: $r-sm; + background: transparent; + color: rgba(255, 255, 255, .6); + cursor: pointer; + transition: .16s; + + &:hover { + border-color: $gold-400; + color: $gold-300; + } +} + +.navCollapsed { + width: 56px; + + .toggle { + align-self: center; + margin: 0 0 $sp-2; + } + + .item { + justify-content: center; + padding: 12px 0; + } +} + +.item { + position: relative; + display: flex; + align-items: center; + gap: $sp-3; + width: 100%; + padding: 12px 18px; + border: 0; + border-left: 3px solid transparent; + background: transparent; + color: rgba(255, 255, 255, .62); + font-family: $font-sans; + font-size: 13.5px; + font-weight: 500; + text-align: left; + white-space: nowrap; + cursor: pointer; + transition: color .18s, background .18s, border-color .18s; + + &:hover { + color: rgba(255, 255, 255, .92); + background: rgba(255, 255, 255, .04); + } +} + +.itemActive { + color: $white; + background: rgba(255, 255, 255, .07); + border-left-color: $gold-500; + + .itemIcon { + color: $gold-400; + } +} + +.itemIcon { + flex-shrink: 0; + display: inline-flex; + color: inherit; +} + +.itemTitle { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.badge { + flex-shrink: 0; + min-width: 18px; + padding: 1px 6px; + border-radius: $r-full; + background: $gold-500; + color: $navy-950; + font-size: $fs-overline; + font-weight: 700; + text-align: center; +} + +// В свёрнутом меню бейдж «прилипает» к иконке, т.к. подписи нет. +.navCollapsed .badge { + position: absolute; + top: 6px; + right: 8px; +} diff --git a/src/webparts/requestPortal/components/SideNav/SideNav.tsx b/src/webparts/requestPortal/components/SideNav/SideNav.tsx new file mode 100644 index 0000000..c46e177 --- /dev/null +++ b/src/webparts/requestPortal/components/SideNav/SideNav.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import styles from './SideNav.module.scss'; +import { Icon } from '../../shared/ui/Icon/Icon'; +import { IconName } from '../../shared/ui/Icon/iconNames'; +import { NavSection } from '../../shared/routing'; + +interface INavItem { + id: NavSection; + title: string; + icon: IconName; +} + +const ITEMS: INavItem[] = [ + { id: 'new', title: 'Новая заявка', icon: 'plus' }, + { id: 'approvals', title: 'На согласовании', icon: 'user-check' }, + { id: 'own', title: 'Мои заявки', icon: 'clipboard' }, +]; + +interface ISideNavProps { + activeId: NavSection; + onChange: (id: NavSection) => void; + /** Счётчик у пункта «На согласовании» (сколько ждут решения). */ + approvalsCount?: number; + collapsed: boolean; + onToggleCollapsed: () => void; +} + +// Тёмное боковое меню портала: переключает разделы (подача заявки / таблицы +// заявок). Сворачивается до узкой полосы с одними иконками. +export const SideNav: React.FC = ({ + activeId, + onChange, + approvalsCount, + collapsed, + onToggleCollapsed, +}) => { + const navClasses = [styles.nav, collapsed ? styles.navCollapsed : ''].filter(Boolean).join(' '); + + return ( + + ); +}; diff --git a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss deleted file mode 100644 index 038b24a..0000000 --- a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss +++ /dev/null @@ -1,33 +0,0 @@ -@import '../../../../styles/design-tokens'; - -// Постоянная боковая панель (всегда открыта). Раскладку колонок задаёт корень; -// здесь — только внутреннее оформление панели. -.panel { - display: flex; - flex-direction: column; - height: 100%; - background: $white; - border: 1px solid $gray-200; - border-radius: $r-lg; - box-shadow: $sh-1; - overflow: hidden; -} - -.header { - padding: $sp-4 $sp-5; - border-bottom: 1px solid $gray-200; -} - -.title { - font-family: $font-serif; - font-size: 18px; - color: $navy-900; -} - -.body { - flex: 1; - overflow-y: auto; - padding: $sp-5; - color: $gray-500; - font-size: $fs-sm; -} diff --git a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx deleted file mode 100644 index bdc0039..0000000 --- a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import * as React from 'react'; -import type { WebPartContext } from '@microsoft/sp-webpart-base'; -import styles from './MyRequestsPanel.module.scss'; - -export interface IMyRequestsPanelProps { - context: WebPartContext; -} - -// Постоянная боковая панель «Мои заявки»: пользователь видит, что он отправлял -// и согласовывал. Всегда открыта (без кнопки/оверлея). -// Владелец: второй разработчик. Вся логика/данные — внутри этой папки. -// Общий UI-кит (shared/ui), pnpjs и config можно импортировать, но НЕ менять. -const MyRequestsPanel: React.FC = (/* { context } */) => { - return ( - - ); -}; - -export default MyRequestsPanel; diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss b/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss new file mode 100644 index 0000000..420e609 --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss @@ -0,0 +1,244 @@ +@import '../../../../styles/design-tokens'; + +.view { + width: 100%; +} + +.head { + display: flex; + align-items: center; + justify-content: space-between; + gap: $sp-4; + flex-wrap: wrap; + margin-bottom: $sp-4; +} + +.heading { + display: flex; + align-items: center; + gap: $sp-3; + margin: 0; + font-family: $font-serif; + font-size: $fs-page-title; + font-weight: 600; + color: $navy-900; +} + +.count { + padding: 2px 10px; + border-radius: $r-full; + background: $navy-100; + color: $navy-700; + font-family: $font-sans; + font-size: $fs-label; + font-weight: 600; +} + +.search { + width: 280px; + max-width: 100%; + 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; + transition: .18s; + + @include input-focus-gold; + + &::placeholder { + color: $gray-400; + } +} + +.empty, +.error { + padding: $sp-6; + background: $white; + border: 1px solid $gray-200; + border-radius: $r-md; + color: $gray-500; + font-size: $fs-sm; +} + +.error { + color: $red-500; + border-color: $red-500; + background: $red-50; +} + +.tableWrap { + background: $white; + border: 1px solid $gray-200; + border-radius: $r-md; + box-shadow: $sh-1; + overflow: hidden; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: $fs-sm; + + th { + text-align: left; + padding: 12px 16px; + background: $gray-50; + border-bottom: 1px solid $gray-200; + color: $gray-500; + font-size: $fs-label; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .04em; + white-space: nowrap; + } + + td { + padding: 14px 16px; + border-bottom: 1px solid $gray-100; + color: $gray-900; + vertical-align: middle; + overflow-wrap: break-word; + word-break: break-word; + } + + tbody tr:last-child td { + border-bottom: 0; + } +} + +// Кликабельна вся строка (см. RequestsTableView — onClick на ). +.row { + cursor: pointer; + transition: background .16s; + + &:hover { + background: $gold-50; + } + + &:focus-visible { + outline: 2px solid $gold-500; + outline-offset: -2px; + } +} + +.colId { + width: 70px; + color: $gray-400; + white-space: nowrap; +} + +.colType { + width: 200px; + color: $navy-700; + font-weight: 500; +} + +.colTitle { + min-width: 240px; +} + +.colDate { + width: 110px; + color: $gray-500; + white-space: nowrap; +} + +.colAuthor { + width: 280px; + color: $gray-500; +} + +.colStatus { + 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; +} + +.pager { + display: flex; + align-items: center; + gap: $sp-1; + margin-top: $sp-4; + flex-wrap: wrap; +} + +.pagerBtn { + min-width: 30px; + height: 30px; + padding: 0 8px; + border: 1px solid $gray-200; + border-radius: $r-sm; + background: $white; + color: $gray-700; + font-family: $font-sans; + font-size: $fs-sm; + cursor: pointer; + transition: .16s; + + &:hover:not(:disabled) { + border-color: $gold-400; + color: $gold-600; + } + + &:disabled { + opacity: .4; + cursor: default; + } +} + +.pagerBtnActive { + background: $navy-800; + border-color: $navy-800; + color: $white; + + &:hover { + border-color: $navy-800 !important; + color: $white !important; + } +} + +.pagerEllipsis { + color: $gray-400; + font-size: $fs-sm; + padding: 0 4px; +} diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx new file mode 100644 index 0000000..16417b9 --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -0,0 +1,283 @@ +import * as React from 'react'; +import type { WebPartContext } from '@microsoft/sp-webpart-base'; +import { getLinkedTypes, getOwnRequests, getPendingApproval } from './myRequests.service'; +import { IMyRequest } from './models/IMyRequest'; +import { REQUEST_STATUS, IRequestType } from '../../shared/approval'; +import { onRequestsChanged } from '../../shared/requestEvents'; +import styles from './RequestsTableView.module.scss'; + +const PAGE_SIZE = 20; + +export interface IRequestsTableViewProps { + context: WebPartContext; + /** 'approvals' — ждут решения текущего пользователя; 'own' — созданные им. */ + kind: 'approvals' | 'own'; + /** Клик по строке — открыть заявку (в выезжающей панели). */ + onOpen: (request: IMyRequest) => void; +} + +function formatDate(iso: string): string { + if (!iso) return ''; + const d = new Date(iso); + const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`; + const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`; + 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; + } +} + +// Постраничная навигация: номера страниц + вперёд/назад. При большом +// количестве страниц показывает только соседние с текущей + первую/последнюю. +const Pager: React.FC<{ page: number; totalPages: number; onChange: (page: number) => void }> = ({ + page, + totalPages, + onChange, +}) => { + if (totalPages <= 1) return null; + + const numbers: number[] = []; + const from = Math.max(1, page - 2); + const to = Math.min(totalPages, page + 2); + for (let i = from; i <= to; i++) numbers.push(i); + + return ( +
+ + {from > 1 && ( + <> + + {from > 2 && …} + + )} + {numbers.map((n) => ( + + ))} + {to < totalPages && ( + <> + {to < totalPages - 1 && …} + + + )} + +
+ ); +}; + +// Таблица заявок. Клик в ЛЮБОМ месте строки открывает заявку (кликабельна вся +// строка, а не только название), поэтому строка — с onClick + tabIndex +// (доступ с клавиатуры: Enter/Space). +// +// «approvals»: автор — не текущий пользователь, поэтому нужна колонка «Автор»; +// статус не показываем — здесь все заявки по определению на согласовании. +// «own»: автор всегда текущий пользователь, зато важен статус. +const Table: React.FC<{ + rows: IMyRequest[]; + kind: 'approvals' | 'own'; + onOpen: (request: IMyRequest) => void; +}> = ({ rows, kind, onOpen }) => ( +
+ + + + + + + + {kind === 'approvals' && } + {kind === 'own' && } + + + + {rows.map((r) => ( + onOpen(r)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onOpen(r); + } + }} + > + + + + + {kind === 'approvals' && ( + + )} + {kind === 'own' && ( + + )} + + ))} + +
IDТипНазваниеДатаАвторСтатус
{r.id}{r.requestTypeName}{r.title || `#${r.id}`}{formatDate(r.created)} + {[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'} + + {r.status || '—'} +
+
+); + +// Раздел с таблицей заявок: поиск по названию + постраничный вывод. +// +// 'approvals' грузится целиком (алгоритм «чья сейчас очередь» требует видеть +// весь маршрут заявки), поиск и пагинация — на клиенте. +// 'own' идёт постранично через обычный REST (см. myRequests.service), поиск — +// серверным OData-фильтром по Title. +// +// Оба варианта перечитываются по событию rp-requests-changed +// (shared/requestEvents) — после создания заявки и после решения согласующего. +export const RequestsTableView: React.FC = ({ context, kind, 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] = React.useState(''); + const [error, setError] = React.useState(''); + + React.useEffect(() => setPage(1), [query, kind]); + + React.useEffect(() => { + let cancelled = false; + getLinkedTypes(context) + .then((t) => { + if (!cancelled) setTypes(t); + }) + .catch((e) => { + if (!cancelled) setError((e as Error).message); + }); + return () => { + cancelled = true; + }; + }, [context]); + + React.useEffect(() => { + if (types === undefined) return; + let cancelled = false; + + const load = (): void => { + const request = + kind === 'own' + ? getOwnRequests(context, types, query, page).then((r) => ({ rows: r.rows, total: r.totalRows })) + : getPendingApproval(context).then((r) => { + console.table(r.debug); + return { rows: r.rows, total: r.rows.length }; + }); + + request + .then((result) => { + if (cancelled) return; + setRows(result.rows); + setTotalRows(result.total); + setError(''); + }) + .catch((e) => { + if (!cancelled) setError((e as Error).message); + }); + }; + + load(); + const unsubscribe = onRequestsChanged(load); + return () => { + cancelled = true; + unsubscribe(); + }; + }, [context, types, kind, query, page]); + + // 'approvals' приходит целиком — фильтруем и нарезаем страницы на клиенте. + const q = query.trim().toLowerCase(); + let visible = rows || []; + let total = totalRows; + if (kind === 'approvals' && rows) { + const filtered = q ? rows.filter((r) => r.title.toLowerCase().indexOf(q) !== -1) : rows; + total = filtered.length; + visible = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + } + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + const heading = kind === 'approvals' ? 'Ждут вашего решения' : 'Мои заявки'; + + return ( +
+
+

+ {heading} + {rows && {total}} +

+ setQuery(e.target.value)} + /> +
+ + {error &&
Не удалось загрузить заявки: {error}
} + {!error && rows === undefined &&
Загрузка…
} + {!error && rows && total === 0 && ( +
+ {q + ? `По запросу «${query}» ничего не найдено.` + : kind === 'approvals' + ? 'Нет заявок, ждущих вашего решения.' + : 'Вы ещё не создавали заявок.'} +
+ )} + {!error && rows && total > 0 && ( + <> + + + + )} + + ); +}; diff --git a/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts new file mode 100644 index 0000000..31d1cb8 --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts @@ -0,0 +1,18 @@ +// Строка заявки пользователя для боковой панели «Мои заявки». +// Собирается из списка данных SharePoint конкретного типа заявки (см. myRequests.service.ts). +export interface IMyRequest { + id: number; + title: string; + status: string; + authorEmail: string; + authorName: string; + /** Дата создания (ISO-строка, колонка Created). */ + created: string; + requestTypeId: number; + requestTypeName: string; + section: string; + /** Ключ формы (rptype в URL режима согласования) — поле pageUrl из approval-route API. */ + formKey: string; + /** 'own' — заявка, созданная текущим пользователем; 'approval' — ждёт его решения. */ + kind: 'own' | 'approval'; +} diff --git a/src/webparts/requestPortal/features/myRequests/models/IMyRequestsDebug.ts b/src/webparts/requestPortal/features/myRequests/models/IMyRequestsDebug.ts new file mode 100644 index 0000000..80c609a --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/models/IMyRequestsDebug.ts @@ -0,0 +1,24 @@ +// Диагностика по одному типу заявки — для отладочного блока в панели «Мои заявки». +export interface IMyRequestsTypeDebug { + requestTypeId: number; + requestTypeName: string; + listId?: string; + siteUrl?: string; + /** Тип пропущен: у него нет listId/siteUrl в approval-route API. */ + skipped: boolean; + /** Сколько строк текущего пользователя найдено (undefined, если не дошли до запроса). */ + rowCount?: number; + /** Текст ошибки, если запрос к списку упал. */ + error?: string; + + /** Заголовок списка согласования, вычисленный из имени списка данных. */ + approvalListTitle?: string; + /** Список данных не похож на «... — данные» — список согласования не искали. */ + approvalSkippedReason?: string; + /** Сколько строк маршрута (фаза «Согласование») прочитано из списка согласования. */ + approvalRowCount?: number; + /** Сколько заявок сейчас ждут решения текущего пользователя. */ + approvalPendingTickets?: number; + /** Текст ошибки, если запрос к списку согласования упал. */ + approvalError?: string; +} diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts new file mode 100644 index 0000000..3ac786c --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -0,0 +1,306 @@ +import { spfi, SPFx } from '@pnp/sp'; +import '@pnp/sp/webs'; +import '@pnp/sp/lists'; +import '@pnp/sp/items'; +import '@pnp/sp/site-users/web'; +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { getRequestTypes, IRequestType, REQUEST_STATUS } from '../../shared/approval'; +import { IMyRequest } from './models/IMyRequest'; +import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug'; + +// Список данных «... — данные» → список согласования «... — согласование» +// (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json). +const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/; + +interface IApprovalStep { + status: string; + order?: number; + approverEmail: string; +} + +export function currentUserEmail(context: WebPartContext): string { + return (context.pageContext.user.email || '').toLowerCase(); +} + +// Типы заявок, у которых approval-route API уже отдал listId/siteUrl (готовы к чтению). +export async function getLinkedTypes(context: WebPartContext): Promise { + const types = await getRequestTypes(context); + return types.filter((t) => !!t.listId && !!t.siteUrl); +} + +// --------------------------------------------------------------------------- +// «Ждёт вашего решения» — требует видеть ВЕСЬ маршрут заявки, чтобы понять, +// чья сейчас очередь, поэтому идёт обычным REST-запросом (не через Search), +// целиком за один раз. Пагинация по этому результату — на стороне панели +// (клиентская нарезка по страницам), т.к. сам список обычно небольшой +// (сколько заявок сейчас реально ждут решения ОДНОГО согласующего). +export async function getPendingApproval( + context: WebPartContext +): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug[] }> { + const types = await getLinkedTypes(context); + const tokenEmail = currentUserEmail(context); + + const perType = await Promise.all( + types.map((t) => getApprovalPendingForType(context, t, tokenEmail)) + ); + + return { + rows: perType.reduce((all, r) => all.concat(r.rows), [] as IMyRequest[]), + debug: perType.map((r) => r.debug), + }; +} + +async function getApprovalPendingForType( + context: WebPartContext, + type: IRequestType, + tokenEmail: string +): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug }> { + const debug: IMyRequestsTypeDebug = { + requestTypeId: type.id, + requestTypeName: type.name, + listId: type.listId, + siteUrl: type.siteUrl, + skipped: false, + }; + + try { + const sp = spfi(type.siteUrl as string).using(SPFx(context)); + const list = sp.web.getList(type.listId as string); + + const dataListInfo = await list.select('Title')(); + const dataTitle = (dataListInfo.Title as string) || ''; + if (!DATA_LIST_SUFFIX.test(dataTitle)) { + debug.approvalSkippedReason = `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`; + return { rows: [], debug }; + } + const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`; + debug.approvalListTitle = approvalListTitle; + const approvalList = sp.web.lists.getByTitle(approvalListTitle); + + // Phase не смотрим вообще — работаем только по Status. + const stepRows = (await approvalList.items + .select('Id', 'StepOrder', 'Status', 'TicketId', 'Approver/EMail') + .expand('Approver') + .top(2000)()) as Array>; + console.log( + `[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», элементов в списке: ${stepRows.length}, ваш email из токена: «${tokenEmail}»` + ); + console.table( + stepRows.map((r) => ({ + Id: r.Id, + TicketId: r.TicketId, + StepOrder: r.StepOrder, + Status: r.Status, + 'Approver/EMail': (r.Approver as { EMail?: string } | undefined)?.EMail, + })) + ); + + debug.approvalRowCount = stepRows.length; + + const byTicket = new Map(); + stepRows.forEach((r) => { + const ticketId = r.TicketId as number | undefined; + if (!ticketId) return; + const approver = r.Approver as { EMail?: string } | undefined; + const steps = byTicket.get(ticketId) || []; + steps.push({ + status: (r.Status as string) || '', + order: typeof r.StepOrder === 'number' ? (r.StepOrder as number) : undefined, + approverEmail: (approver?.EMail || '').toLowerCase(), + }); + byTicket.set(ticketId, steps); + }); + + const myTicketIds: number[] = []; + const byTicketDebug: Array> = []; + byTicket.forEach((steps, ticketId) => { + const mine = isMyTurn(steps, tokenEmail); + byTicketDebug.push({ TicketId: ticketId, isMyTurn: mine, steps: JSON.stringify(steps) }); + if (mine) myTicketIds.push(ticketId); + }); + console.table(byTicketDebug); + + debug.approvalPendingTickets = myTicketIds.length; + if (myTicketIds.length === 0) return { rows: [], debug }; + + const ticketFilter = myTicketIds.map((id) => `Id eq ${id}`).join(' or '); + const ticketRows = (await list.items + .filter(ticketFilter) + .select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title') + .expand('Author') + .top(2000)()) as Array>; + + const rows = ticketRows.map((r) => { + const author = r.Author as { EMail?: string; Title?: string } | undefined; + return toMyRequest(r, author, type, 'approval'); + }); + return { rows, debug }; + } catch (e) { + debug.approvalError = (e as Error).message; + console.error( + `Мои заявки: не удалось прочитать согласование для «${type.name}»:`, + debug.approvalError + ); + return { rows: [], debug }; + } +} + +// Сейчас ли очередь пользователя с данным email среди строк маршрута одной заявки. +// Маршрут остановлен (где-то есть Отклонено/Возвращена) — не его очередь, заявка +// не активна. Иначе «текущие» строки — это ещё не решённые (Status = На согласовании) +// без указанного StepOrder (параллельно, все активны сразу) плюс, если есть строки +// с указанным StepOrder, ещё не решённая с минимальным StepOrder (последовательно). +function isMyTurn(steps: IApprovalStep[], tokenEmail: string): boolean { + if (!tokenEmail) return false; + const stopped = steps.some( + (s) => s.status === REQUEST_STATUS.rejected || s.status === REQUEST_STATUS.returned + ); + if (stopped) return false; + + const pending = steps.filter((s) => s.status === REQUEST_STATUS.onApproval); + const noOrder = pending.filter((s) => s.order === undefined); + const ordered = pending.filter((s): s is IApprovalStep & { order: number } => s.order !== undefined); + + const current = [...noOrder]; + if (ordered.length > 0) { + const minOrder = Math.min(...ordered.map((s) => s.order)); + current.push(...ordered.filter((s) => s.order === minOrder)); + } + + return current.some((s) => s.approverEmail === tokenEmail); +} + +// --------------------------------------------------------------------------- +// «Созданы вами» — обычный REST по спискам данных (НЕ Search): данные всегда +// актуальны, только что созданная заявка видна сразу (у поискового индекса +// была бы задержка переиндексации). +// +// Пагинация «сшивает» несколько списков в один нумерованный список: сначала по +// каждому типу берём только лёгкие поля (Id + Created) всех заявок автора, +// сортируем объединённый набор по дате создания и лишь для нужной страницы +// дочитываем полные поля. Так на экран не тянется всё содержимое заявок, +// но общее число страниц известно точно. +export interface IOwnRequestsPage { + rows: IMyRequest[]; + totalRows: number; +} + +const PAGE_SIZE = 20; +// Столько «лёгких» строк (Id + Created) максимум читаем из одного списка, чтобы +// посчитать общее число страниц. Хватает с большим запасом; если у кого-то +// заявок окажется больше — в консоль уйдёт предупреждение. +const INDEX_LIMIT = 5000; + +// В OData-строке одинарная кавычка экранируется удвоением. +function escapeOData(text: string): string { + return text.replace(/'/g, "''"); +} + +interface IOwnIndexEntry { + id: number; + created: string; + type: IRequestType; +} + +export async function getOwnRequests( + context: WebPartContext, + types: IRequestType[], + query: string, + page: number +): Promise { + if (types.length === 0) return { rows: [], totalRows: 0 }; + + const q = query.trim(); + + // 1) Лёгкий индекс по каждому типу: только Id + Created заявок автора. + const perType = await Promise.all( + types.map(async (type): Promise => { + try { + const sp = spfi(type.siteUrl as string).using(SPFx(context)); + const me = await sp.web.currentUser.select('Id')(); + const filters = [`AuthorId eq ${me.Id}`]; + if (q) filters.push(`substringof('${escapeOData(q)}',Title)`); + + const rows = (await sp.web + .getList(type.listId as string) + .items.filter(filters.join(' and ')) + .select('Id', 'Created') + .orderBy('Created', false) + .top(INDEX_LIMIT)()) as Array>; + + if (rows.length === INDEX_LIMIT) { + console.warn( + `[Текущие заявки] «${type.name}»: индекс своих заявок обрезан на ${INDEX_LIMIT} — часть страниц не видна` + ); + } + return rows.map((r) => ({ id: r.Id as number, created: (r.Created as string) || '', type })); + } catch (e) { + console.error(`[Текущие заявки] «${type.name}»: не удалось прочитать свои заявки:`, (e as Error).message); + return []; + } + }) + ); + + // 2) Общий список всех типов, свежие сверху. + const index = perType + .reduce((all, rows) => all.concat(rows), [] as IOwnIndexEntry[]) + .sort((a, b) => b.created.localeCompare(a.created)); + + const pageEntries = index.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + if (pageEntries.length === 0) { + return { rows: [], totalRows: index.length }; + } + + // 3) Полные поля — только для заявок текущей страницы, по своему списку на тип. + const idsByType = new Map(); + pageEntries.forEach((e) => { + const ids = idsByType.get(e.type) || []; + ids.push(e.id); + idsByType.set(e.type, ids); + }); + + const byKey = new Map(); + await Promise.all( + Array.from(idsByType.entries()).map(async ([type, ids]) => { + const sp = spfi(type.siteUrl as string).using(SPFx(context)); + 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') + .expand('Author') + .top(PAGE_SIZE)()) as Array>; + rows.forEach((r) => { + const author = r.Author as { EMail?: string; Title?: string } | undefined; + byKey.set(`${type.id}-${r.Id as number}`, toMyRequest(r, author, type, 'own')); + }); + }) + ); + + // Порядок страницы задаёт индекс (по дате), а не порядок ответов REST. + const rows = pageEntries + .map((e) => byKey.get(`${e.type.id}-${e.id}`)) + .filter((r): r is IMyRequest => !!r); + + return { rows, totalRows: index.length }; +} + +function toMyRequest( + r: Record, + author: { EMail?: string; Title?: string } | undefined, + type: IRequestType, + kind: 'own' | 'approval' +): IMyRequest { + return { + id: r.Id as number, + title: (r.Title as string) || '', + status: (r.Status as string) || '', + authorEmail: author?.EMail || '', + authorName: author?.Title || '', + created: (r.Created as string) || '', + requestTypeId: type.id, + requestTypeName: type.name, + section: type.section, + formKey: type.pageUrl, + kind, + }; +} diff --git a/src/webparts/requestPortal/features/submit/SubmitFeature.module.scss b/src/webparts/requestPortal/features/submit/SubmitFeature.module.scss index 3210d2a..9b24de6 100644 --- a/src/webparts/requestPortal/features/submit/SubmitFeature.module.scss +++ b/src/webparts/requestPortal/features/submit/SubmitFeature.module.scss @@ -1,16 +1,16 @@ @import '../../../../styles/design-tokens'; -// Центрированная колонка контента. 1080px — ширина контента из дизайн-системы -// (как .sec в референсе): форма и плитки читаются аккуратно, не растягиваясь. +// Колонка контента. 1080px — ширина контента из дизайн-системы (как .sec в +// референсе): форма и плитки читаются аккуратно, не растягиваясь. Прижата +// влево (без margin: auto), иначе на широком экране между боковым меню и +// формой появлялась пустая полоса, а вкладки категорий сверху шли шире формы. .content { width: 100%; max-width: 1080px; - margin: 0 auto; } // ── Заглушка формы (до появления реальных форм) ── .formPlaceholder { - margin-top: $sp-6; padding: $sp-8; background: $white; border: 1px solid $gray-200; @@ -44,7 +44,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..a43881e 100644 --- a/src/webparts/requestPortal/features/submit/SubmitFeature.tsx +++ b/src/webparts/requestPortal/features/submit/SubmitFeature.tsx @@ -1,41 +1,40 @@ 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'; +import { emitRequestsChanged } from '../../shared/requestEvents'; 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); @@ -62,19 +61,19 @@ const SubmitFeature: React.FC = ({ context, initialForm }) // Ленивый рендер формы: её код подтягивается только сейчас, при открытии. return ( Загрузка формы…}> - + ); }; return (
- - {selectedRequest ? ( renderSelected(selectedRequest) ) : ( diff --git a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts index 2b5f91e..2f0f22b 100644 --- a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts +++ b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts @@ -6,6 +6,7 @@ import { ApprovalListService } from './approvalList.service'; import { REQUEST_STATUS, APPROVAL_PHASE } from './models/statuses'; import { IApprovalRow } from './models/IApprovalRow'; import { ISiteUser } from './models/ISiteUser'; +import { emitRequestsChanged } from '../requestEvents'; export type ApproverDecision = 'approve' | 'return' | 'reject'; @@ -74,6 +75,7 @@ export class ApproverService { const rows = await this.approval.getForRequest(requestId); const requestStatus = this.computeRequestStatus(rows); await this.updateRequestStatus(requestId, requestStatus); + emitRequestsChanged(); return requestStatus; } diff --git a/src/webparts/requestPortal/shared/approval/models/IApprovalRouteApi.ts b/src/webparts/requestPortal/shared/approval/models/IApprovalRouteApi.ts index bf623ea..3e139a8 100644 --- a/src/webparts/requestPortal/shared/approval/models/IApprovalRouteApi.ts +++ b/src/webparts/requestPortal/shared/approval/models/IApprovalRouteApi.ts @@ -8,6 +8,11 @@ export interface IRequestType { id: number; name: string; pageUrl: string; + /** Серверный относительный URL списка данных (напр. "/sites/requests/Lists/List1"). undefined, пока не настроено. */ + listId?: string; + /** Абсолютный URL сайта, где живёт этот список. undefined, пока не настроено. */ + siteUrl?: string; + section: string; } // Обёртка ответа API (все ответы приходят в поле data). diff --git a/src/webparts/requestPortal/shared/requestEvents.ts b/src/webparts/requestPortal/shared/requestEvents.ts new file mode 100644 index 0000000..80ee54a --- /dev/null +++ b/src/webparts/requestPortal/shared/requestEvents.ts @@ -0,0 +1,15 @@ +// Общее событие «список заявок изменился»: создана новая заявка или принято +// решение согласующего. Панель «Текущие заявки» (features/myRequests) подписана +// и перечитывает данные. Через window.dispatchEvent — проще шины состояния, +// достаточно для нечастых событий (сохранение формы, решение согласующего). +const EVENT_NAME = 'rp-requests-changed'; + +export function emitRequestsChanged(): void { + window.dispatchEvent(new Event(EVENT_NAME)); +} + +/** Подписка на изменения. Возвращает функцию отписки (вызвать в cleanup эффекта). */ +export function onRequestsChanged(handler: () => void): () => void { + window.addEventListener(EVENT_NAME, handler); + return () => window.removeEventListener(EVENT_NAME, handler); +} diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index 389e813..b075473 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -1,18 +1,33 @@ +import * as React from 'react'; + // Маршрутизация портала по параметрам URL. // -// Обычный режим (нет параметров) — подача заявок (features/submit). -// Режим согласующего — переход по ссылке из потока Power Automate: +// Раздел бокового меню — rpnav: 'new' (подать заявку, по умолчанию), +// 'approvals' (ждут моего решения), 'own' (мои заявки). +// Открытая заявка — переход по ссылке из потока Power Automate или клик по +// строке таблицы: // …/СтраницаПортала.aspx?rpmode=approve&rptype=<форма>&rpid= -// где rptype = ключ формы в реестре (forms/registry), rpid = RowID заявки в списке данных. +// где rptype = ключ формы в реестре (forms/registry), rpid = RowID заявки в +// списке данных. Заявка показывается в выезжающей панели ПОВЕРХ текущего +// раздела, поэтому rpnav сохраняется вместе с rpmode. // // Префикс rp (request-portal): имена id/mode/type зарезервированы SharePoint // (напр. ?id= он трактует как навигацию к элементу → «Элемент не существует»), // поэтому используем собственные, неконфликтные имена параметров. +// +// Навигация внутри портала идёт через navigate() — это history.pushState без +// перезагрузки страницы. useRoute() подписан и на popstate (кнопка «назад»), +// и на её собственное событие, поэтому реагирует на оба способа смены URL. export type PortalMode = 'submit' | 'approve' | 'create'; +/** Раздел бокового меню. */ +export type NavSection = 'new' | 'approvals' | 'own'; + export interface IRoute { mode: PortalMode; + /** Раздел бокового меню; сохраняется и когда поверх открыта заявка. */ + nav: NavSection; /** Ключ формы (тип заявки) — для 'approve' и 'create'. */ formKey?: string; /** Id заявки в списке данных — для 'approve'. */ @@ -21,23 +36,88 @@ export interface IRoute { sourceId?: number; } +const ROUTE_CHANGE_EVENT = 'rp-route-change'; + +function parseNav(raw: string | undefined): NavSection { + if (raw === 'approvals' || raw === 'own') return raw; + return 'new'; +} + /** Разобрать строку запроса (по умолчанию — из адреса страницы) в маршрут. */ export function parseRoute(search: string = window.location.search): IRoute { const params = new URLSearchParams(search); const mode = params.get('rpmode'); + const nav = parseNav(params.get('rpnav') ?? undefined); const formKey = params.get('rptype') ?? undefined; const idRaw = params.get('rpid'); const itemId = idRaw ? Number(idRaw) : undefined; const sourceRaw = params.get('rpsource'); const sourceId = sourceRaw ? Number(sourceRaw) : undefined; - // Режим согласующего — при полном наборе параметров. + // Открытая заявка (панель поверх раздела) — при полном наборе параметров. if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) { - return { mode: 'approve', formKey, itemId }; + return { mode: 'approve', nav, formKey, itemId }; } // Создание производной заявки с предзаполнением из исходной. if (formKey && sourceId && !isNaN(sourceId)) { - return { mode: 'create', formKey, sourceId }; + return { mode: 'create', nav: 'new', formKey, sourceId }; } - return { mode: 'submit' }; + return { mode: 'submit', nav }; +} + +// Собрать query-строку маршрута, сохранив посторонние параметры страницы. +function buildQuery(route: IRoute): string { + const params = new URLSearchParams(window.location.search); + params.delete('rpmode'); + params.delete('rpnav'); + params.delete('rptype'); + params.delete('rpid'); + params.delete('rpsource'); + + if (route.nav !== 'new') { + params.set('rpnav', route.nav); + } + 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}`; +} + +/** + * Перейти на другой маршрут без перезагрузки страницы (SPA): обновляет + * query-параметры адресной строки через history.pushState и оповещает + * useRoute() своим событием (popstate на pushState сам не срабатывает). + */ +export function navigate(route: IRoute): void { + window.history.pushState({}, '', buildQuery(route)); + window.dispatchEvent(new Event(ROUTE_CHANGE_EVENT)); +} + +/** Ссылка на маршрут (для href — открытие в новой вкладке, копирование и т.п.). */ +export function routeUrl(route: IRoute): string { + return buildQuery(route); +} + +/** Текущий маршрут, реактивно обновляется по 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; }