Архивация списков #13

Merged
gorbenko.pavel merged 16 commits from archive into main 2026-08-19 09:08:37 +00:00
9 changed files with 79 additions and 3 deletions
Showing only changes of commit 8df1b86e1b - Show all commits
@@ -1,9 +1,11 @@
import * as React from 'react'; import * as React from 'react';
import styles from './DashboardScreen.module.scss'; import styles from './DashboardScreen.module.scss';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { RequestTypeListSeries } from '../../models';
import { ScreenKey } from '../app/types'; import { ScreenKey } from '../app/types';
import { texts } from '../../texts'; import { texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes'; import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useRequestTypeListsAll } from '../../hooks/useRequestTypeListsAll';
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes'; import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
import { AsyncList } from '../../hooks/useAsyncList'; import { AsyncList } from '../../hooks/useAsyncList';
import { CardGrid, NavCard, StatCard, StatRow } from '../ui'; import { CardGrid, NavCard, StatCard, StatRow } from '../ui';
@@ -20,15 +22,24 @@ function statValue(list: AsyncList<unknown>): React.ReactNode {
return list.items.length; 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 }) => { export const DashboardScreen: React.FC<IDashboardScreenProps> = ({ api, onNavigate }) => {
const requestTypes = useRequestTypes(api); const requestTypes = useRequestTypes(api);
const routes = useTemplateRoutes(api); const routes = useTemplateRoutes(api);
const listPartitions = useRequestTypeListsAll(api);
return ( return (
<div> <div>
<StatRow> <StatRow>
<StatCard label={texts.dashboard.requestTypesStat} value={statValue(requestTypes)} /> <StatCard label={texts.dashboard.requestTypesStat} value={statValue(requestTypes)} />
<StatCard label={texts.dashboard.routesStat} value={statValue(routes)} /> <StatCard label={texts.dashboard.routesStat} value={statValue(routes)} />
<StatCard label={texts.dashboard.listPartitionsStat} value={partitionsCount(listPartitions)} />
</StatRow> </StatRow>
<div className={styles.sectionsLabel}>{texts.dashboard.sections}</div> <div className={styles.sectionsLabel}>{texts.dashboard.sections}</div>
@@ -32,6 +32,21 @@
margin-top: 6px; 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 на // Колонка с номером шага — узкая, содержимое по центру (важно при rowSpan на
// параллельный блок: иначе номер прижимался бы к верхней строке блока). // параллельный блок: иначе номер прижимался бы к верхней строке блока).
.stepCol { .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> <Badge variant={statusVariant(matrix.request.status)}>{matrix.request.status || texts.common.empty}</Badge>
</div> </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>} {readOnly && <Alert kind="warn">{texts.manageRequests.fullyClosedNotice}</Alert>}
{!matrix.approvalListTitle && <Alert kind="warn">{texts.manageRequests.noApprovalList}</Alert>} {!matrix.approvalListTitle && <Alert kind="warn">{texts.manageRequests.noApprovalList}</Alert>}
@@ -2,6 +2,7 @@
export * from './enums'; export * from './enums';
export * from './dictionaries'; export * from './dictionaries';
export * from './requestTypes'; export * from './requestTypes';
export * from './requestTypeLists';
export * from './templateRoutes'; export * from './templateRoutes';
export * from './templateRouteSteps'; export * from './templateRouteSteps';
export * from './resolve'; export * from './resolve';
@@ -127,6 +127,13 @@ export interface ManagedRequestMatrix {
siteUrl: string; siteUrl: string;
/** Title списка «… — данные» (для тела запроса к потоку оповещения, см. RequestManageService.notifyRow). */ /** Title списка «… — данные» (для тела запроса к потоку оповещения, см. RequestManageService.notifyRow). */
dataListTitle: string; dataListTitle: string;
/**
* Абсолютный URL списка данных — заявки нумерованные (списки-партиции, см.
* ТЗ по архивации), и по мере роста текущий список меняется. Ссылка нужна,
* чтобы администратор видел, в каком физическом списке лежит открытая
* заявка, и мог перейти туда напрямую, не выясняя это вручную.
*/
dataListUrl: string;
/** Title списка «… — согласование» (вычислен из Title списка данных). */ /** Title списка «… — согласование» (вычислен из Title списка данных). */
approvalListTitle: string; approvalListTitle: string;
/** Обе фазы вместе, упорядочены по StepOrder. */ /** Обе фазы вместе, упорядочены по 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, PatchTemplateRouteInput,
PatchTemplateRouteStepInput, PatchTemplateRouteStepInput,
RequestType, RequestType,
RequestTypeListSeries,
ResolveTemplateRouteQuery, ResolveTemplateRouteQuery,
ResolveTemplateRouteResult, ResolveTemplateRouteResult,
TemplateRoute, TemplateRoute,
@@ -39,7 +40,14 @@ export class ApprovalRouteApi {
this.http.patch<RequestType>(`/api/request-types/${id}`, body), this.http.patch<RequestType>(`/api/request-types/${id}`, body),
remove: (id: number): Promise<void> => 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 = { public readonly templateRoutes = {
@@ -207,8 +207,17 @@ export class RequestManageService {
this.listTitles(sp, requestType.listId) this.listTitles(sp, requestType.listId)
]); ]);
const dataListUrl = listAbsUrl(requestType.siteUrl, requestType.listId);
if (!approvalListTitle) { 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); 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). */ /** email → числовой Id пользователя сайта (Person-поле требует Id, не email). */
@@ -89,6 +89,7 @@ export const texts = {
dashboard: { dashboard: {
requestTypesStat: 'Типов заявок', requestTypesStat: 'Типов заявок',
routesStat: 'Маршрутов согласования', routesStat: 'Маршрутов согласования',
listPartitionsStat: 'Партиций списков SharePoint',
sections: 'Разделы', sections: 'Разделы',
requestTypesCard: { requestTypesCard: {
title: 'Типы заявок', title: 'Типы заявок',
@@ -385,6 +386,7 @@ export const texts = {
requestDisabledHint: 'Сначала выберите тип заявки.', requestDisabledHint: 'Сначала выберите тип заявки.',
noApprovalList: 'У этого типа заявки список согласования не найден (Title списка данных не оканчивается на «— данные»).', noApprovalList: 'У этого типа заявки список согласования не найден (Title списка данных не оканчивается на «— данные»).',
requestStatusLabel: 'Статус заявки', requestStatusLabel: 'Статус заявки',
listLinkLabel: (listTitle: string): string => `Список: ${listTitle}`,
fullyClosedNotice: 'Заявка полностью закрыта — маршрут доступен только для просмотра.', fullyClosedNotice: 'Заявка полностью закрыта — маршрут доступен только для просмотра.',
approvalPhaseTitle: 'Согласование', approvalPhaseTitle: 'Согласование',
executionPhaseTitle: 'Исполнение', executionPhaseTitle: 'Исполнение',