Архивация списков (#13)
Tests / frontend-test (push) Successful in 44s
Tests / backend-test (push) Failing after 17m4s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/13
This commit was merged in pull request #13.
This commit is contained in:
2026-08-19 09:08:36 +00:00
parent a2672042d7
commit 82512eb1db
56 changed files with 2587 additions and 51 deletions
@@ -1,7 +1,7 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import { type IPropertyPaneConfiguration, PropertyPaneTextField } from '@microsoft/sp-property-pane';
import { type IPropertyPaneConfiguration, PropertyPaneDropdown, PropertyPaneTextField } from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import ApprovalAdmin from './components/ApprovalAdmin';
@@ -13,6 +13,13 @@ export interface IApprovalAdminWebPartProps {
portalBaseUrl: string;
}
// Единственные два адреса, которыми реально пользуются (прод и локальный backend,
// см. backend/Tnsu.ApprovalRoute.Api/Properties/launchSettings.json) — селект
// вместо текстового поля, чтобы переключаться одним кликом, а не перепечатывать
// url и рисковать опечаткой в проде.
const API_BASE_URL_PROD = 'https://api.tnsu.kz/approval-route';
const API_BASE_URL_LOCAL = 'http://localhost:5080';
export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprovalAdminWebPartProps> {
public render(): void {
@@ -28,9 +35,8 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprova
protected onInit(): Promise<void> {
// Прод-адрес по умолчанию: пакуем именно под прод (heft package-solution --production).
// Для локальной разработки переопределите значение в панели свойств веб-части
// (например, на http://localhost:5080 — порт из backend/.../launchSettings.json).
this.properties.apiBaseUrl = this.properties.apiBaseUrl || 'https://api.tnsu.kz/approval-route';
// Для локальной разработки переключите в панели свойств веб-части на «Локально».
this.properties.apiBaseUrl = this.properties.apiBaseUrl || API_BASE_URL_PROD;
this.properties.aadResourceId = this.properties.aadResourceId || 'api://9232d9e2-9807-4753-808f-07250e5474b0';
this.properties.portalBaseUrl = this.properties.portalBaseUrl || 'https://api.tnsu.kz';
return Promise.resolve();
@@ -55,9 +61,13 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprova
{
groupName: 'API',
groupFields: [
PropertyPaneTextField('apiBaseUrl', {
PropertyPaneDropdown('apiBaseUrl', {
label: 'Api Base Url',
description: 'По умолчанию — прод (https://api.tnsu.kz/approval-route). Для локальной разработки укажите http://localhost:5080.'
selectedKey: this.properties.apiBaseUrl,
options: [
{ key: API_BASE_URL_PROD, text: `Прод (${API_BASE_URL_PROD})` },
{ key: API_BASE_URL_LOCAL, text: `Локально (${API_BASE_URL_LOCAL})` }
]
}),
PropertyPaneTextField('aadResourceId', {
label: 'AAD Resource Id (App ID URI)',
@@ -1,9 +1,11 @@
import * as React from 'react';
import styles from './DashboardScreen.module.scss';
import { ApprovalRouteApi } from '../../services/api';
import { RequestTypeListSeries } from '../../models';
import { ScreenKey } from '../app/types';
import { texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useRequestTypeListsAll } from '../../hooks/useRequestTypeListsAll';
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
import { AsyncList } from '../../hooks/useAsyncList';
import { CardGrid, NavCard, StatCard, StatRow } from '../ui';
@@ -20,15 +22,24 @@ function statValue(list: AsyncList<unknown>): React.ReactNode {
return list.items.length;
}
// Партиций много на серию (архив + текущая) — считаем суммарно по всем типам
// заявок, а не количество самих серий (это и так есть в requestTypesStat).
function partitionsCount(list: AsyncList<RequestTypeListSeries>): React.ReactNode {
if (list.isLoading || list.error) return texts.common.empty;
return list.items.reduce((sum, series) => sum + series.partitions.length, 0);
}
export const DashboardScreen: React.FC<IDashboardScreenProps> = ({ api, onNavigate }) => {
const requestTypes = useRequestTypes(api);
const routes = useTemplateRoutes(api);
const listPartitions = useRequestTypeListsAll(api);
return (
<div>
<StatRow>
<StatCard label={texts.dashboard.requestTypesStat} value={statValue(requestTypes)} />
<StatCard label={texts.dashboard.routesStat} value={statValue(routes)} />
<StatCard label={texts.dashboard.listPartitionsStat} value={partitionsCount(listPartitions)} />
</StatRow>
<div className={styles.sectionsLabel}>{texts.dashboard.sections}</div>
@@ -52,6 +63,12 @@ export const DashboardScreen: React.FC<IDashboardScreenProps> = ({ api, onNaviga
description={texts.dashboard.resolveCard.description}
onClick={() => onNavigate('resolve')}
/>
<NavCard
icon="edit"
title={texts.dashboard.manageRequestsCard.title}
description={texts.dashboard.manageRequestsCard.description}
onClick={() => onNavigate('manageRequests')}
/>
</CardGrid>
</div>
);
@@ -32,6 +32,21 @@
margin-top: 6px;
}
// Ссылка на физический список-партицию — второстепенная деталь под заголовком
// заявки, поэтому приглушённая, а не акцентная (как обычная ссылка $blue-600).
.listLink {
display: inline-block;
margin-top: 4px;
font-size: 12px;
color: $gray-500;
text-decoration: none;
&:hover {
color: $blue-600;
text-decoration: underline;
}
}
// Колонка с номером шага — узкая, содержимое по центру (важно при rowSpan на
// параллельный блок: иначе номер прижимался бы к верхней строке блока).
.stepCol {
@@ -560,6 +560,13 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
<Badge variant={statusVariant(matrix.request.status)}>{matrix.request.status || texts.common.empty}</Badge>
</div>
{/* Список — партиция (см. ТЗ по архивации SharePoint-списков): по мере
роста заявки одного типа переезжают на новый список, и без этой
ссылки не всегда очевидно, какой именно список сейчас открыт. */}
<a className={styles.listLink} href={matrix.dataListUrl} target="_blank" rel="noopener noreferrer">
{texts.manageRequests.listLinkLabel(matrix.dataListTitle)}
</a>
{readOnly && <Alert kind="warn">{texts.manageRequests.fullyClosedNotice}</Alert>}
{!matrix.approvalListTitle && <Alert kind="warn">{texts.manageRequests.noApprovalList}</Alert>}
@@ -0,0 +1,10 @@
import * as React from 'react';
import { RequestTypeListSeries } from '../models';
import { ApprovalRouteApi } from '../services/api';
import { AsyncList, useAsyncList } from './useAsyncList';
/** Серии партиций списков сразу по всем типам заявок — сейчас нужен только дашборду (счётчик партиций). */
export function useRequestTypeListsAll(api: ApprovalRouteApi): AsyncList<RequestTypeListSeries> {
const load = React.useCallback(() => api.requestTypes.listsAll(), [api]);
return useAsyncList(load);
}
@@ -2,6 +2,7 @@
export * from './enums';
export * from './dictionaries';
export * from './requestTypes';
export * from './requestTypeLists';
export * from './templateRoutes';
export * from './templateRouteSteps';
export * from './resolve';
@@ -127,6 +127,13 @@ export interface ManagedRequestMatrix {
siteUrl: string;
/** Title списка «… — данные» (для тела запроса к потоку оповещения, см. RequestManageService.notifyRow). */
dataListTitle: string;
/**
* Абсолютный URL списка данных — заявки нумерованные (списки-партиции, см.
* ТЗ по архивации), и по мере роста текущий список меняется. Ссылка нужна,
* чтобы администратор видел, в каком физическом списке лежит открытая
* заявка, и мог перейти туда напрямую, не выясняя это вручную.
*/
dataListUrl: string;
/** Title списка «… — согласование» (вычислен из Title списка данных). */
approvalListTitle: string;
/** Обе фазы вместе, упорядочены по StepOrder. */
@@ -0,0 +1,16 @@
// DTO эндпоинта api/request-types/lists (Application/ListPartitioning/*.cs) —
// нумерованная серия SharePoint-списков (партиций) по типу заявки: архив + текущий.
export interface RequestListPartition {
number: number;
siteUrl: string;
listId: string;
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable JSON-контракт бэкенда (Title? в RequestListPartition)
title: string | null;
isCurrent: boolean;
createdAt: string;
}
export interface RequestTypeListSeries {
requestTypeId: number;
partitions: RequestListPartition[];
}
@@ -7,6 +7,7 @@ import {
PatchTemplateRouteInput,
PatchTemplateRouteStepInput,
RequestType,
RequestTypeListSeries,
ResolveTemplateRouteQuery,
ResolveTemplateRouteResult,
TemplateRoute,
@@ -39,7 +40,14 @@ export class ApprovalRouteApi {
this.http.patch<RequestType>(`/api/request-types/${id}`, body),
remove: (id: number): Promise<void> =>
this.http.delete(`/api/request-types/${id}`)
this.http.delete(`/api/request-types/${id}`),
// Серии партиций СРАЗУ по всем типам заявок (см.
// ListPartitionService.ListAllAsync) — для дашборда (счётчик партиций) и
// портала, которому нужен весь список адресов разом, а не по одному запросу
// на тип.
listsAll: (): Promise<RequestTypeListSeries[]> =>
this.http.get<RequestTypeListSeries[]>('/api/request-types/lists')
};
public readonly templateRoutes = {
@@ -207,8 +207,17 @@ export class RequestManageService {
this.listTitles(sp, requestType.listId)
]);
const dataListUrl = listAbsUrl(requestType.siteUrl, requestType.listId);
if (!approvalListTitle) {
return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, dataListTitle, approvalListTitle: '', rows: [] };
return {
request: toSummary(requestRow),
siteUrl: requestType.siteUrl,
dataListTitle,
dataListUrl,
approvalListTitle: '',
rows: []
};
}
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
@@ -255,7 +264,7 @@ export class RequestManageService {
};
});
return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, dataListTitle, approvalListTitle, rows };
return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, dataListTitle, dataListUrl, approvalListTitle, rows };
}
/** email → числовой Id пользователя сайта (Person-поле требует Id, не email). */
@@ -89,6 +89,7 @@ export const texts = {
dashboard: {
requestTypesStat: 'Типов заявок',
routesStat: 'Маршрутов согласования',
listPartitionsStat: 'Партиций списков SharePoint',
sections: 'Разделы',
requestTypesCard: {
title: 'Типы заявок',
@@ -101,6 +102,10 @@ export const texts = {
resolveCard: {
title: 'Проверка маршрута',
description: 'Как разрешится маршрут для конкретного пользователя'
},
manageRequestsCard: {
title: 'Редактирование заявок',
description: 'Правка маршрута согласования конкретной заявки'
}
},
@@ -385,6 +390,7 @@ export const texts = {
requestDisabledHint: 'Сначала выберите тип заявки.',
noApprovalList: 'У этого типа заявки список согласования не найден (Title списка данных не оканчивается на «— данные»).',
requestStatusLabel: 'Статус заявки',
listLinkLabel: (listTitle: string): string => `Список: ${listTitle}`,
fullyClosedNotice: 'Заявка полностью закрыта — маршрут доступен только для просмотра.',
approvalPhaseTitle: 'Согласование',
executionPhaseTitle: 'Исполнение',