From 4b97745fec37080d640fcf8a74056721cb26757a Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 3 Aug 2026 18:00:19 +0500 Subject: [PATCH 1/8] =?UTF-8?q?=D0=BB=D0=B5=D0=B2=D0=B0=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20=D0=B3=D0=BE=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + .../components/RequestPortal.tsx | 18 +-- .../myRequests/MyRequestsPanel.module.scss | 110 +++++++++++++++++- .../features/myRequests/MyRequestsPanel.tsx | 103 +++++++++++++++- .../features/myRequests/models/IMyRequest.ts | 14 +++ .../myRequests/models/IMyRequestsDebug.ts | 20 ++++ .../features/myRequests/myRequests.service.ts | 99 ++++++++++++++++ .../approval/models/IApprovalRouteApi.ts | 5 + 8 files changed, 358 insertions(+), 14 deletions(-) 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 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/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 6a43257..1236ea4 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -5,17 +5,10 @@ import { IconSprite } from '../shared/ui/Icon/IconSprite'; import { parseRoute } from '../shared/routing'; import SubmitFeature from '../features/submit/SubmitFeature'; import ApproveFeature from '../features/approve/ApproveFeature'; +import MyRequestsPanel from '../features/myRequests/MyRequestsPanel'; // Оболочка портала. Логики фич тут нет — файл общий, меняется редко. // Роутинг по URL: mode=approve&type=&id= → фича согласования, иначе — подача. -// -// Панель «Мои заявки» (features/myRequests) готова как компонент, но НЕ смонтирована — -// её подключает второй разработчик, когда наполнит. Для этого: -// 1) import MyRequestsPanel from '../features/myRequests/MyRequestsPanel'; -// 2) обернуть контент подачи в
с двумя колонками: -//
-//
-// (классы layout/main/side уже есть в RequestPortal.module.scss) const RequestPortal: React.FC = ({ hasTeamsContext, context }) => { const rootClasses = [styles.requestPortal, hasTeamsContext ? styles.teams : ''] .filter(Boolean) @@ -29,7 +22,14 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context {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 038b24a..a577f1f 100644 --- a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss +++ b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss @@ -28,6 +28,114 @@ flex: 1; overflow-y: auto; padding: $sp-5; - color: $gray-500; font-size: $fs-sm; } + +.empty, +.error { + color: $gray-500; +} + +.error { + color: $red-500; +} + +.group + .group { + margin-top: $sp-5; +} + +.groupTitle { + font-family: $font-serif; + font-size: $fs-label; + font-weight: 600; + color: $navy-800; + margin-bottom: $sp-2; +} + +.list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: $sp-2; +} + +.item { + display: flex; + align-items: center; + justify-content: space-between; + gap: $sp-2; + padding: $sp-2 $sp-3; + border: 1px solid $gray-200; + border-radius: $r-sm; + background: $gray-50; +} + +.itemMain { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + overflow: hidden; +} + +.itemTitle { + color: $gray-900; + font-size: $fs-sm; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.itemAuthor { + color: $gray-500; + font-size: $fs-caption; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.itemDate { + color: $gray-400; + font-size: $fs-caption; +} + +.status { + flex-shrink: 0; + padding: 2px 8px; + border-radius: $r-full; + font-size: $fs-caption; + font-weight: 500; + white-space: nowrap; +} + +.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; +} diff --git a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx index bdc0039..1c42263 100644 --- a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx +++ b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx @@ -1,23 +1,118 @@ import * as React from 'react'; 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 styles from './MyRequestsPanel.module.scss'; export interface IMyRequestsPanelProps { context: WebPartContext; } -// Постоянная боковая панель «Мои заявки»: пользователь видит, что он отправлял -// и согласовывал. Всегда открыта (без кнопки/оверлея). +interface IGroup { + requestTypeName: string; + rows: IMyRequest[]; +} + +function groupByType(rows: IMyRequest[]): IGroup[] { + const order: string[] = []; + const map: Record = {}; + rows.forEach((r) => { + if (!map[r.requestTypeName]) { + map[r.requestTypeName] = []; + order.push(r.requestTypeName); + } + map[r.requestTypeName].push(r); + }); + return order.map((name) => ({ requestTypeName: name, rows: map[name] })); +} + +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; + } +} + +// Постоянная боковая панель «Мои заявки»: заявки, которые создал текущий +// пользователь, по всем типам заявок, известным approval-route API. // Владелец: второй разработчик. Вся логика/данные — внутри этой папки. // Общий UI-кит (shared/ui), pnpjs и config можно импортировать, но НЕ менять. -const MyRequestsPanel: React.FC = (/* { context } */) => { +// +// Диагностика по типам заявок (listId/siteUrl/rowCount/error) в UI не выводится — +// см. console.table в devtools при загрузке панели. +const MyRequestsPanel: React.FC = ({ context }) => { + const [rows, setRows] = React.useState(undefined); + const [error, setError] = React.useState(''); + + React.useEffect(() => { + let cancelled = false; + getMyRequests(context) + .then((result) => { + if (cancelled) return; + setRows(result.rows); + console.table(result.debug); + }) + .catch((e) => { + if (!cancelled) setError((e as Error).message); + }); + return () => { + cancelled = true; + }; + }, [context]); + + const groups = rows ? groupByType(rows) : []; + return ( ); 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..40e1acc --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts @@ -0,0 +1,14 @@ +// Строка заявки пользователя для боковой панели «Мои заявки». +// Собирается из списка данных 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; +} 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..6afdf65 --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/models/IMyRequestsDebug.ts @@ -0,0 +1,20 @@ +import { IMyRequest } from './IMyRequest'; + +// Диагностика по одному типу заявки — для отладочного блока в панели «Мои заявки». +export interface IMyRequestsTypeDebug { + requestTypeId: number; + requestTypeName: string; + listId?: string; + siteUrl?: string; + /** Тип пропущен: у него нет listId/siteUrl в approval-route API. */ + skipped: boolean; + /** Сколько строк текущего пользователя найдено (undefined, если не дошли до запроса). */ + rowCount?: number; + /** Текст ошибки, если запрос к списку упал. */ + error?: string; +} + +export interface IMyRequestsResult { + rows: IMyRequest[]; + debug: IMyRequestsTypeDebug[]; +} 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..e55361a --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -0,0 +1,99 @@ +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 } from '../../shared/approval'; +import { IMyRequest } from './models/IMyRequest'; +import { IMyRequestsResult, IMyRequestsTypeDebug } from './models/IMyRequestsDebug'; + +// Заявки текущего пользователя по всем типам заявок, известным approval-route API. +// Для каждого типа с заполненными listId/siteUrl читаем список данных на его сайте +// и берём строки, где Author — текущий пользователь. Типы без listId/siteUrl +// (ещё не настроены на бэкенде) пропускаются. Ошибка по одному списку (нет прав, +// список удалён и т.п.) не должна ронять всю панель — такие типы просто дают +// пустой результат. debug возвращается всегда — панель показывает его как +// отладочную информацию (что пропущено и почему, что упало с ошибкой). +export async function getMyRequests(context: WebPartContext): Promise { + const types = await getRequestTypes(context); + + const perType = await Promise.all( + types.map((t) => (t.listId && t.siteUrl ? getMyRequestsForType(context, t) : skipType(t))) + ); + + return { + rows: perType.reduce((all, r) => all.concat(r.rows), [] as IMyRequest[]), + debug: perType.map((r) => r.debug), + }; +} + +function skipType(type: IRequestType): { rows: IMyRequest[]; debug: IMyRequestsTypeDebug } { + return { + rows: [], + debug: { + requestTypeId: type.id, + requestTypeName: type.name, + listId: type.listId, + siteUrl: type.siteUrl, + skipped: true, + }, + }; +} + +async function getMyRequestsForType( + context: WebPartContext, + type: IRequestType +): 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 me = await sp.web.currentUser.select('Id')(); + + const rows = (await list.items + .filter(`AuthorId eq ${me.Id}`) + .select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title') + .expand('Author') + .orderBy('Id', false) + .top(200)()) as Array>; + + debug.rowCount = rows.length; + + // Токен пользователя (context.pageContext.user.email) — источник правды для + // сравнения с Author/EMail списка. Не совпало (напр. Author/EMail пуст на + // профиле сайта) — строку не показываем, а не показываем что попало. + const tokenEmail = (context.pageContext.user.email || '').toLowerCase(); + + const mapped = rows + .map((r) => { + const author = r.Author as { EMail?: string; Title?: string } | undefined; + 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, + }; + }) + .filter((r) => !!tokenEmail && r.authorEmail.toLowerCase() === tokenEmail); + + return { rows: mapped, debug }; + } catch (e) { + debug.error = (e as Error).message; + console.error(`Мои заявки: не удалось прочитать список типа «${type.name}»:`, debug.error); + return { rows: [], debug }; + } +} diff --git a/src/webparts/requestPortal/shared/approval/models/IApprovalRouteApi.ts b/src/webparts/requestPortal/shared/approval/models/IApprovalRouteApi.ts index 6c9d4a1..a0ae620 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). -- 2.34.1 From 79e44e01be70611b73923073de94437868ddd2e6 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 4 Aug 2026 12:09:47 +0500 Subject: [PATCH 2/8] =?UTF-8?q?=D1=82=D0=B5=D0=BF=D0=B5=D1=80=D1=8C=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=B6=D0=BD=D0=BE=20=D0=BF=D1=80=D0=BE=D0=B9=D1=82?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D1=83=20?= =?UTF-8?q?=D0=B2=20=D0=BC=D0=BE=D0=B8=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA?= =?UTF-8?q?=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}
- - {r.status || '—'} - + ))} 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; +} -- 2.34.1 From 687ba5a75326096c94f40354f0fe9728a7bbfbd8 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 4 Aug 2026 14:25:19 +0500 Subject: [PATCH 3/8] =?UTF-8?q?=D1=82=D0=B5=D0=BF=D0=B5=D1=80=D1=8C=20?= =?UTF-8?q?=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D0=B8=20=D0=BD=D0=B0=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B3=D0=BB=D0=B0=D1=81=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=82=D0=BE=D0=B6=D0=B5=20=D0=BF=D0=B8=D1=88=D1=83?= =?UTF-8?q?=D1=82=D1=8C=D1=81=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../myRequests/MyRequestsPanel.module.scss | 16 ++ .../features/myRequests/MyRequestsPanel.tsx | 105 +++++---- .../features/myRequests/models/IMyRequest.ts | 2 + .../myRequests/models/IMyRequestsDebug.ts | 11 + .../features/myRequests/myRequests.service.ts | 223 ++++++++++++++---- 5 files changed, 276 insertions(+), 81 deletions(-) diff --git a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss index 2edac8e..c8773e2 100644 --- a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss +++ b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss @@ -40,6 +40,22 @@ color: $red-500; } +.section + .section { + margin-top: $sp-6; + padding-top: $sp-6; + border-top: 1px solid $gray-200; +} + +.sectionTitle { + font-family: $font-sans; + font-size: $fs-caption; + font-weight: 600; + color: $gray-500; + text-transform: uppercase; + letter-spacing: .04em; + margin-bottom: $sp-4; +} + .group + .group { margin-top: $sp-5; } diff --git a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx index 0413c0f..a70a910 100644 --- a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx +++ b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx @@ -53,8 +53,52 @@ function statusClass(status: string): string { } } -// Постоянная боковая панель «Мои заявки»: заявки, которые создал текущий -// пользователь, по всем типам заявок, известным approval-route API. +const RequestItem: React.FC<{ r: IMyRequest }> = ({ r }) => ( +
  • + { + // Обычный левый клик — SPA-переход (без перезагрузки страницы). + // Ctrl/Cmd/Shift/среднюю кнопку — не трогаем, пусть браузер сам + // откроет в новой вкладке по href. + if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { + return; + } + e.preventDefault(); + navigate({ mode: 'approve', formKey: r.formKey, itemId: r.id }); + }} + > +
    + {r.title || `#${r.id}`} + + {r.authorEmail || r.authorName || '— автор не определён —'} + + {r.created && {formatDate(r.created)}} +
    + {r.status || '—'} +
    +
  • +); + +const RequestGroups: React.FC<{ rows: IMyRequest[] }> = ({ rows }) => ( + <> + {groupByType(rows).map((group) => ( +
    +
    {group.requestTypeName}
    +
      + {group.rows.map((r) => ( + + ))} +
    +
    + ))} + +); + +// Постоянная боковая панель «Текущие заявки»: заявки, созданные текущим +// пользователем, и заявки, ждущие сейчас его решения как согласующего — +// по всем типам заявок, известным approval-route API. // Владелец: второй разработчик. Вся логика/данные — внутри этой папки. // Общий UI-кит (shared/ui), pnpjs и config можно импортировать, но НЕ менять. // @@ -80,55 +124,34 @@ const MyRequestsPanel: React.FC = ({ context }) => { }; }, [context]); - const groups = rows ? groupByType(rows) : []; + const pendingApproval = rows ? rows.filter((r) => r.kind === 'approval') : []; + const own = rows ? rows.filter((r) => r.kind === 'own') : []; return ( -
    diff --git a/src/webparts/requestPortal/features/submit/SubmitFeature.tsx b/src/webparts/requestPortal/features/submit/SubmitFeature.tsx index 62ae41c..a43881e 100644 --- a/src/webparts/requestPortal/features/submit/SubmitFeature.tsx +++ b/src/webparts/requestPortal/features/submit/SubmitFeature.tsx @@ -7,6 +7,7 @@ import { RequestGrid } from '../../components/RequestGrid/RequestGrid'; 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; @@ -60,7 +61,13 @@ const SubmitFeature: React.FC = ({ context, activeCategoryI // Ленивый рендер формы: её код подтягивается только сейчас, при открытии. return ( Загрузка формы…}> - + ); }; 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/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); +} -- 2.34.1 From 3684c75a0ca724280d7d4affc92de16a2add2fcb Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 4 Aug 2026 15:38:05 +0500 Subject: [PATCH 5/8] =?UTF-8?q?=D0=BA=D1=83=D1=87=D0=B0=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/RequestPortal.module.scss | 5 + .../components/RequestPortal.tsx | 31 +- .../myRequests/MyRequestsPanel.module.scss | 113 ++++++- .../features/myRequests/MyRequestsPanel.tsx | 295 ++++++++++++++---- .../myRequests/models/IMyRequestsDebug.ts | 7 - .../features/myRequests/myRequests.service.ts | 227 +++++++++----- 6 files changed, 520 insertions(+), 158 deletions(-) diff --git a/src/webparts/requestPortal/components/RequestPortal.module.scss b/src/webparts/requestPortal/components/RequestPortal.module.scss index 646caaa..ba8e39d 100644 --- a/src/webparts/requestPortal/components/RequestPortal.module.scss +++ b/src/webparts/requestPortal/components/RequestPortal.module.scss @@ -52,6 +52,11 @@ position: sticky; top: $sp-4; align-self: stretch; + transition: flex-basis .2s; +} + +.sideCollapsed { + flex-basis: 44px; } .tabsWrap { diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 99d9aea..e993475 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -23,6 +23,7 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context .join(' '); const route = useRoute(); + const [panelCollapsed, setPanelCollapsed] = React.useState(false); const [activeCategoryId, setActiveCategoryId] = React.useState(() => { if (route.mode === 'create' && route.formKey) { @@ -32,19 +33,43 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context return DEFAULT_CATEGORY_ID; }); + // Клик по заявке в «Текущие заявки» (или ссылка «создать на основе») — переключает + // вкладку категории на ту, к которой относится тип этой заявки, чтобы вкладки + // сверху совпадали с тем, что сейчас открыто в main. + React.useEffect(() => { + if ((route.mode === 'approve' || route.mode === 'create') && route.formKey) { + const found = REQUEST_TYPES.filter((r) => r.id === route.formKey)[0]; + if (found) setActiveCategoryId(found.categoryId); + } + }, [route.mode, route.formKey]); + return (
    -
    - +
    + setPanelCollapsed((v) => !v)} + />
    { + setActiveCategoryId(id); + // Клик по вкладке всегда возвращает к списку плиток: в режиме + // подачи это уже делает сам SubmitFeature (сброс выбранной формы + // при смене категории), а в режиме согласования (заявка открыта + // из «Текущие заявки») route сам не меняется — выходим явно, + // чтобы поведение вкладок было одинаковым в обоих случаях. + if (route.mode === 'approve') { + navigate({ mode: 'submit' }); + } + }} />
    {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 c8773e2..b5f2983 100644 --- a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss +++ b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss @@ -14,16 +14,68 @@ } .header { + display: flex; + align-items: center; + justify-content: space-between; + gap: $sp-2; padding: $sp-4 $sp-5; border-bottom: 1px solid $gray-200; } +.collapseToggle { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; + border: 1px solid $gray-300; + border-radius: $r-sm; + background: $white; + color: $navy-700; + cursor: pointer; + transition: .16s; + + &:hover { + border-color: $gold-400; + color: $gold-600; + } +} + +.panelCollapsed { + align-items: center; + padding: $sp-3 0; +} + .title { font-family: $font-serif; font-size: 18px; color: $navy-900; } +.searchWrap { + padding: $sp-3 $sp-5 0; +} + +.search { + width: 100%; + padding: 8px 12px; + 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; + } +} + .body { flex: 1; overflow-y: auto; @@ -56,16 +108,53 @@ margin-bottom: $sp-4; } -.group + .group { - margin-top: $sp-5; +.pager { + display: flex; + align-items: center; + gap: 4px; + margin-top: $sp-3; + flex-wrap: wrap; } -.groupTitle { - font-family: $font-serif; - font-size: $fs-label; - font-weight: 600; - color: $navy-800; - margin-bottom: $sp-2; +.pagerBtn { + min-width: 24px; + height: 24px; + padding: 0 6px; + border: 1px solid $gray-200; + border-radius: $r-sm; + background: $white; + color: $gray-700; + font-family: $font-sans; + font-size: $fs-caption; + 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-caption; + padding: 0 2px; } .list { @@ -103,6 +192,14 @@ overflow: hidden; } +.itemType { + color: $gold-600; + font-size: $fs-caption; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .03em; +} + .itemTitle { color: $gray-900; font-size: $fs-sm; diff --git a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx index 0d278d2..44c5363 100644 --- a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx +++ b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx @@ -1,33 +1,21 @@ import * as React from 'react'; import type { WebPartContext } from '@microsoft/sp-webpart-base'; -import { getMyRequests } from './myRequests.service'; +import { getLinkedTypes, getPendingApproval, searchOwnRequests } from './myRequests.service'; import { IMyRequest } from './models/IMyRequest'; -import { REQUEST_STATUS } from '../../shared/approval'; +import { REQUEST_STATUS, IRequestType } from '../../shared/approval'; import { navigate, routeUrl } from '../../shared/routing'; import { onRequestsChanged } from '../../shared/requestEvents'; +import { Icon } from '../../shared/ui/Icon/Icon'; import styles from './MyRequestsPanel.module.scss'; export interface IMyRequestsPanelProps { context: WebPartContext; + /** Свёрнута ли панель (управляется снаружи — RequestPortal меняет ширину колонки). */ + collapsed?: boolean; + onToggleCollapsed?: () => void; } -interface IGroup { - requestTypeName: string; - rows: IMyRequest[]; -} - -function groupByType(rows: IMyRequest[]): IGroup[] { - const order: string[] = []; - const map: Record = {}; - rows.forEach((r) => { - if (!map[r.requestTypeName]) { - map[r.requestTypeName] = []; - order.push(r.requestTypeName); - } - map[r.requestTypeName].push(r); - }); - return order.map((name) => ({ requestTypeName: name, rows: map[name] })); -} +const PAGE_SIZE = 20; function formatDate(iso: string): string { if (!iso) return ''; @@ -74,6 +62,7 @@ const RequestItem: React.FC<{ r: IMyRequest; variant: 'own' | 'approval' }> = ({ }} >
    + {r.requestTypeName} {r.title || `#${r.id}`} {variant === 'approval' && ( @@ -89,50 +78,206 @@ const RequestItem: React.FC<{ r: IMyRequest; variant: 'own' | 'approval' }> = ({ ); -const RequestGroups: React.FC<{ rows: IMyRequest[]; variant: 'own' | 'approval' }> = ({ - rows, - variant, -}) => ( - <> - {groupByType(rows).map((group) => ( -
    -
    {group.requestTypeName}
    -
      - {group.rows.map((r) => ( - - ))} -
    -
    - ))} - -); +// Постраничная навигация: номера страниц + вперёд/назад. При большом +// количестве страниц показывает только соседние с текущей + первую/последнюю. +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 && …} + + + )} + +
    + ); +}; + +// «Ждёт вашего решения»: данные уже загружены целиком (алгоритм требует видеть +// весь маршрут заявки, чтобы понять, чья сейчас очередь) — один общий список +// по всем типам, пагинация — клиентская нарезка по этому списку. +const PendingSection: React.FC<{ rows: IMyRequest[] }> = ({ rows }) => { + const [page, setPage] = React.useState(1); + React.useEffect(() => setPage(1), [rows]); + + if (rows.length === 0) { + return
    Нет заявок, ждущих вашего решения.
    ; + } + + const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE)); + const pageRows = rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + + return ( + <> +
      + {pageRows.map((r) => ( + + ))} +
    + + + ); +}; + +// «Созданы вами»: один общий постраничный поиск по всем типам сразу через +// SharePoint Search REST API (см. myRequests.service.searchOwnRequests) — +// единая пагинация, без разбивки списка по типам заявок. +const OwnSection: React.FC<{ context: WebPartContext; types: IRequestType[]; query: string }> = ({ + context, + types, + query, +}) => { + const [page, setPage] = React.useState(1); + const [rows, setRows] = React.useState([]); + const [totalRows, setTotalRows] = React.useState(undefined); + const [error, setError] = React.useState(''); + + React.useEffect(() => setPage(1), [query]); + + React.useEffect(() => { + if (types.length === 0) { + setTotalRows(0); + return; + } + let cancelled = false; + searchOwnRequests(context, types, query, page) + .then((result) => { + if (cancelled) return; + setRows(result.rows); + setTotalRows(result.totalRows); + setError(''); + }) + .catch((e) => { + if (!cancelled) setError((e as Error).message); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [context, types, query, page]); + + if (error) { + return
    Не удалось загрузить: {error}
    ; + } + + if (totalRows === undefined) { + return
    Загрузка…
    ; + } + if (totalRows === 0) { + return
    Вы ещё не создавали заявок.
    ; + } + + const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE)); + + return ( + <> +
      + {rows.map((r) => ( + + ))} +
    + + + ); +}; // Постоянная боковая панель «Текущие заявки»: заявки, созданные текущим // пользователем, и заявки, ждущие сейчас его решения как согласующего — -// по всем типам заявок, известным approval-route API. +// по всем типам заявок, известным approval-route API, в двух общих списках +// без разбивки по типам (тип виден на самой карточке). // Владелец: второй разработчик. Вся логика/данные — внутри этой папки. // Общий UI-кит (shared/ui), pnpjs и config можно импортировать, но НЕ менять. // -// Перечитывает данные по событию rp-requests-changed (shared/requestEvents) — -// оно шлётся после сохранения формы (SubmitFeature) и после решения -// согласующего (ApproverService.decide), так что панель обновляется сама, -// без перезагрузки страницы. +// «Ждёт вашего решения» перечитывается по событию rp-requests-changed +// (shared/requestEvents) — оно шлётся после сохранения формы и после решения +// согласующего. «Созданы вами» идёт через SharePoint Search (см. +// myRequests.service) постранично — намеренно НЕ обновляется по этому +// событию: только что созданная заявка появится там, когда её проиндексирует +// поиск (задержка приемлема). // -// Диагностика по типам заявок (listId/siteUrl/rowCount/error) в UI не выводится — -// см. console.table в devtools при загрузке панели. -const MyRequestsPanel: React.FC = ({ context }) => { - const [rows, setRows] = React.useState(undefined); +// Поиск по названию: «Ждёт вашего решения» фильтруется на клиенте (данные уже +// загружены целиком), «Созданы вами» — реальный запрос к Search REST API +// (может слегка отставать от только что созданных/переименованных заявок). +// +// Диагностика (listId/siteUrl/rowCount/error, KQL-запросы) в UI не выводится — +// см. console.log/console.table в devtools при загрузке панели. +const MyRequestsPanel: React.FC = ({ context, collapsed, onToggleCollapsed }) => { + const [linkedTypes, setLinkedTypes] = React.useState(undefined); + const [pendingRows, setPendingRows] = React.useState(undefined); const [error, setError] = React.useState(''); + const [query, setQuery] = React.useState(''); + + React.useEffect(() => { + let cancelled = false; + getLinkedTypes(context) + .then((types) => { + if (!cancelled) setLinkedTypes(types); + }) + .catch((e) => { + if (!cancelled) setError((e as Error).message); + }); + return () => { + cancelled = true; + }; + }, [context]); React.useEffect(() => { let cancelled = false; const load = (): void => { - getMyRequests(context) + getPendingApproval(context) .then((result) => { if (cancelled) return; - setRows(result.rows); - setError(''); + setPendingRows(result.rows); console.table(result.debug); }) .catch((e) => { @@ -148,32 +293,66 @@ const MyRequestsPanel: React.FC = ({ context }) => { }; }, [context]); - const pendingApproval = rows ? rows.filter((r) => r.kind === 'approval') : []; - const own = rows ? rows.filter((r) => r.kind === 'own') : []; + const q = query.trim().toLowerCase(); + const filteredPending = + pendingRows && q ? pendingRows.filter((r) => r.title.toLowerCase().indexOf(q) !== -1) : pendingRows; + + const nothingLoaded = linkedTypes !== undefined && pendingRows !== undefined; + + if (collapsed) { + return ( + + ); + } return (