Архивация списков (#13)
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:
@@ -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>
|
||||
);
|
||||
|
||||
+15
@@ -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: 'Исполнение',
|
||||
|
||||
Reference in New Issue
Block a user