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

Merged
gorbenko.pavel merged 17 commits from archive into main 2026-08-19 09:07:53 +00:00
67 changed files with 2617 additions and 901 deletions
@@ -1,7 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import * as ReactDom from 'react-dom'; import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library'; import { Version } from '@microsoft/sp-core-library';
import { type IPropertyPaneConfiguration } from '@microsoft/sp-property-pane'; import { type IPropertyPaneConfiguration, PropertyPaneDropdown } from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base'; import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import { IReadonlyTheme } from '@microsoft/sp-component-base'; import { IReadonlyTheme } from '@microsoft/sp-component-base';
@@ -11,14 +11,23 @@ import { IRequestPortalProps } from './components/IRequestPortalProps';
import { initSP } from './shared/pnp/pnpjs'; import { initSP } from './shared/pnp/pnpjs';
import { installErrorLogging } from './shared/diagnostics'; import { installErrorLogging } from './shared/diagnostics';
import { setImpersonatedIdentity } from './shared/impersonation'; import { setImpersonatedIdentity } from './shared/impersonation';
import { APPROVAL_API_BASE_URL_LOCAL, APPROVAL_API_BASE_URL_PROD, setApprovalApiBaseUrl } from './shared/config';
import { initListPartitions } from './shared/approval/listPartitions';
import { ErrorOverlay } from './shared/ui'; import { ErrorOverlay } from './shared/ui';
import { PropertyPaneDebugUser } from './propertyPane/PropertyPaneDebugUser'; import { PropertyPaneDebugUser } from './propertyPane/PropertyPaneDebugUser';
export interface IRequestPortalWebPartProps { export interface IRequestPortalWebPartProps {
/** Отладочная подмена личности — см. shared/impersonation.ts. На бою пусто. */ /**
* Отладочная подмена личности — см. shared/impersonation.ts. На бою пусто.
* Одно поле на оба случая: e-mail, введённый напрямую, хранится как есть;
* ФИО, найденное поиском по имени (см. propertyPane/DebugUserField), после
* резолва заменяется собой же на найденный e-mail — отдельное поле под
* «сырой» ввод не нужно, DebugUserField ведёт его в своём локальном состоянии.
*/
debugUserQuery: string; debugUserQuery: string;
debugUserEmail: string;
debugUserDisplayName: string; debugUserDisplayName: string;
/** Куда шлём запросы approval-route (маршруты согласования) — прод или локальный backend. */
approvalApiBaseUrl: string;
} }
export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequestPortalWebPartProps> { export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequestPortalWebPartProps> {
@@ -29,7 +38,9 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
public render(): void { public render(): void {
// Именно здесь, а не в onInit: свойство меняется в панели без перезапуска // Именно здесь, а не в onInit: свойство меняется в панели без перезапуска
// веб-части, и render вызывается на каждое такое изменение. // веб-части, и render вызывается на каждое такое изменение.
setImpersonatedIdentity(this.properties.debugUserEmail, this.properties.debugUserDisplayName); setImpersonatedIdentity(this.properties.debugUserQuery, this.properties.debugUserDisplayName);
this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD;
setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl);
const element: React.ReactElement = React.createElement( const element: React.ReactElement = React.createElement(
ErrorOverlay, ErrorOverlay,
@@ -39,7 +50,7 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
// пересобрать раздел заново — иначе счётчики в боковом меню и открытые // пересобрать раздел заново — иначе счётчики в боковом меню и открытые
// таблицы остались бы от прежнего пользователя (их эффекты завязаны на // таблицы остались бы от прежнего пользователя (их эффекты завязаны на
// монтирование/context, а не на identity подмены). // монтирование/context, а не на identity подмены).
key: this.properties.debugUserEmail || '__self__', key: this.properties.debugUserQuery || '__self__',
isDarkTheme: this._isDarkTheme, isDarkTheme: this._isDarkTheme,
environmentMessage: this._environmentMessage, environmentMessage: this._environmentMessage,
hasTeamsContext: !!this.context.sdks.microsoftTeams, hasTeamsContext: !!this.context.sdks.microsoftTeams,
@@ -54,7 +65,18 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
protected onInit(): Promise<void> { protected onInit(): Promise<void> {
installErrorLogging(); // до всего остального — чтобы поймать и ранние отклонения installErrorLogging(); // до всего остального — чтобы поймать и ранние отклонения
initSP(this.context); // единый клиент PnPjs для записи в списки initSP(this.context); // единый клиент PnPjs для записи в списки
return this._getEnvironmentMessage().then(message => {
// Адрес approval-route должен быть выставлен ДО загрузки партиций — они
// берутся именно оттуда. В render() он ставится снова (свойство меняется в
// панели без перезапуска), но render случается позже onInit.
this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD;
setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl);
// Партиции грузим здесь, а не по требованию из форм: имена списков нужны
// формам синхронно (см. shared/listTitles.ts), а формы — лениво подгружаемые
// чанки, монтируются заведомо позже onInit. initListPartitions не бросает:
// при недоступном approval-route портал работает по конвенции имён.
return Promise.all([this._getEnvironmentMessage(), initListPartitions(this.context)]).then(([message]) => {
this._environmentMessage = message; this._environmentMessage = message;
}); });
} }
@@ -131,13 +153,38 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
context: this.context, context: this.context,
initialQuery: this.properties.debugUserQuery, initialQuery: this.properties.debugUserQuery,
onResolved: (query, email, displayName) => { onResolved: (query, email, displayName) => {
this.properties.debugUserQuery = query; // Сохраняем ТОЛЬКО e-mail (введённый напрямую или найденный по
this.properties.debugUserEmail = email; // ФИО) — свойство одно и то же используется как e-mail для
this.properties.debugUserDisplayName = displayName; // подмены (см. render() выше), так что писать в него сырое ФИО
this.render(); // нельзя. Пустой query — поле очистили, подмену выключаем.
// А вот «email пуст, но query не пуст» — ФИО ещё не нашлось
// (недописано/не существует): ничего не трогаем, иначе неудачный
// промежуточный поиск молча выключил бы уже работающую подмену.
if (email) {
this.properties.debugUserQuery = email;
this.properties.debugUserDisplayName = displayName;
this.render();
} else if (!query) {
this.properties.debugUserQuery = '';
this.properties.debugUserDisplayName = '';
this.render();
}
} }
}) })
] ]
},
{
groupName: 'Approval Route API',
groupFields: [
PropertyPaneDropdown('approvalApiBaseUrl', {
label: 'Api Base Url',
selectedKey: this.properties.approvalApiBaseUrl,
options: [
{ key: APPROVAL_API_BASE_URL_PROD, text: `Прод (${APPROVAL_API_BASE_URL_PROD})` },
{ key: APPROVAL_API_BASE_URL_LOCAL, text: `Локально (${APPROVAL_API_BASE_URL_LOCAL})` }
]
})
]
} }
] ]
} }
@@ -55,21 +55,44 @@
min-height: 78vh; // «присутствие» рейла даже когда контента мало min-height: 78vh; // «присутствие» рейла даже когда контента мало
} }
// Рейл-колонка: раньше тянулась по высоте .main (align-items: stretch), из-за // Рейл-колонка.
// чего высота рейла плавала вместе с содержимым соседней колонки — например, у //
// вкладки «Инициированные мной» появляется доп. фильтр по статусу, .main // Здесь сходятся два требования, которые по отдельности решаются по-разному:
// становится чуть выше, и градиентный фон Sidebar (посчитанный от своей же //
// высоты) заметно менял оттенок. Прибиваем рейл к высоте окна (sticky + 100vh): // 1. Градиент рейла не должен менять оттенок от высоты соседней колонки. Раньше
// теперь он не зависит от того, что показано рядом. На мобильном брейкпоинте // колонка тянулась по .main (align-items: stretch), и, например, доп. фильтр
// сам Sidebar уходит в position:fixed (см. @tnsu/ui-kit) — там эти правила уже // по статусу на вкладке «Инициированные мной» делал .main чуть выше — фон
// не действуют. // Sidebar (градиент от своей же высоты, см. $grad-header в @tnsu/ui-kit)
// заметно менял тон. Поэтому САМ РЕЙЛ прибит к высоте окна (100vh) и от
// содержимого рядом больше не зависит.
// 2. Под рейлом не должно быть белого провала на длинных таблицах. Прежняя
// версия задавала height:100vh самой КОЛОНКЕ — и всё, что ниже первого
// экрана, оставалось непрокрашенным: на длинном списке заявок рейл выглядел
// обрезанным. Ставку на sticky тут держать нельзя: в SharePoint прокручивается
// не окно, а внутренний контейнер страницы, и sticky там нередко не
// включается вовсе — тогда рейл просто уезжает вверх вместе с контентом.
//
// Отсюда разделение: КОЛОНКА тянется во всю высоту раскладки и закрашена
// $navy-800 — это цвет, которым градиент рейла заканчивается (на 85% и до
// конца), поэтому стык с липкой частью не виден. РЕЙЛ внутри — sticky на 100vh
// поверх этой заливки. Не включился sticky — под рейлом всё равно та же
// заливка, а не белизна.
//
// На мобильном брейкпоинте сам Sidebar уходит в position:fixed (см.
// @tnsu/ui-kit) — там эти правила уже не действуют, см. @media ниже.
.side { .side {
flex: 0 0 auto; flex: 0 0 auto;
display: flex; display: flex;
position: sticky; align-self: stretch;
top: 0; background: $navy-800;
align-self: flex-start;
height: 100vh; // Прямой потомок — корень Sidebar из @tnsu/ui-kit (своего класса снаружи не
// видно, поэтому селектор по структуре, а не по имени).
> * {
position: sticky;
top: 0;
height: 100vh;
}
} }
.main { .main {
@@ -109,6 +132,20 @@
.mobileNav { .mobileNav {
display: block; display: block;
} }
// Снимаем десктопные правила рейла явно. Здесь ui-kit сам делает Sidebar
// position:fixed (top/bottom/left), и наш sticky+100vh спорил бы с ним при
// равной специфичности — исход решался бы порядком подключения стилей.
// Заливка колонки тоже не нужна: колонка схлопнута, drawer красит себя сам.
.side {
align-self: flex-start;
background: none;
> * {
position: static;
height: auto;
}
}
} }
// Полоса отладочной подмены личности (см. shared/impersonation.ts). Красная и // Полоса отладочной подмены личности (см. shared/impersonation.ts). Красная и
@@ -228,6 +228,11 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
nav: route.nav, nav: route.nav,
formKey: request.formKey, formKey: request.formKey,
itemId: request.id, itemId: request.id,
// Заявки нумерованные (см. shared/approval/listPartitions.ts) — строка
// таблицы уже знает, в каком СПИСКЕ она реально лежит (см. IMyRequest.listId
// в myRequests.service.ts), и это надо передать дальше, иначе архивная
// заявка откроется так, будто лежит в текущей партиции.
listId: request.listId,
ownTab: route.ownTab, ownTab: route.ownTab,
ownStatus: route.ownStatus, ownStatus: route.ownStatus,
// Страница и запрос — часть того же списка под панелью: без них ссылка на // Страница и запрос — часть того же списка под панелью: без них ссылка на
@@ -378,7 +383,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
{route.mode === 'approve' && route.formKey && route.itemId && ( {route.mode === 'approve' && route.formKey && route.itemId && (
<Drawer title={`Заявка №${route.itemId}`} onClose={closeDrawer}> <Drawer title={`Заявка №${route.itemId}`} onClose={closeDrawer}>
<ApproveFeature context={context} formKey={route.formKey} itemId={route.itemId} /> <ApproveFeature context={context} formKey={route.formKey} itemId={route.itemId} listId={route.listId} />
</Drawer> </Drawer>
)} )}
@@ -391,6 +396,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
context={context} context={context}
formKey={route.formKey} formKey={route.formKey}
itemId={route.itemId} itemId={route.itemId}
listId={route.listId}
onClose={closeDrawer} onClose={closeDrawer}
/> />
</div> </div>
@@ -2,6 +2,7 @@ import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './ApproveFeature.module.scss'; import styles from './ApproveFeature.module.scss';
import { getViewComponent } from '../../forms/viewRegistry'; import { getViewComponent } from '../../forms/viewRegistry';
import { isConfirmedStaleListId } from '../../shared/approval';
export interface IApproveFeatureProps { export interface IApproveFeatureProps {
context: WebPartContext; context: WebPartContext;
@@ -9,18 +10,29 @@ export interface IApproveFeatureProps {
formKey: string; formKey: string;
/** RowID заявки из URL. */ /** RowID заявки из URL. */
itemId: number; itemId: number;
/** Партиция, где лежит заявка (см. IRoute.listId) — не задана у ссылок старого формата. */
listId?: string;
} }
// Фича «Согласование»: открывается по ссылке из потока (mode=approve&type=&id=). // Фича «Согласование»: открывается по ссылке из потока (mode=approve&type=&id=).
// Показывает заявку в режиме чтения; панель решения добавим следующим шагом. // Показывает заявку в режиме чтения; панель решения добавим следующим шагом.
const ApproveFeature: React.FC<IApproveFeatureProps> = ({ context, formKey, itemId }) => { const ApproveFeature: React.FC<IApproveFeatureProps> = ({ context, formKey, itemId, listId }) => {
const ViewComponent = getViewComponent(formKey); const ViewComponent = getViewComponent(formKey);
// Партиция указана и реестр ТОЧНО знает, что её там нет — см. тот же
// комментарий в EditFeature (isConfirmedStaleListId).
const partitionError =
listId && isConfirmedStaleListId(formKey, listId)
? `Не удалось открыть заявку: список «${listId}» не найден в реестре партиций. Ссылка могла устареть — попробуйте открыть заявку заново из списка.`
: undefined;
return ( return (
<div className={styles.content}> <div className={styles.content}>
{ViewComponent ? ( {partitionError ? (
<div className={styles.state}>{partitionError}</div>
) : ViewComponent ? (
<React.Suspense fallback={<div className={styles.state}>Загрузка…</div>}> <React.Suspense fallback={<div className={styles.state}>Загрузка…</div>}>
<ViewComponent context={context} itemId={itemId} formKey={formKey} /> <ViewComponent context={context} itemId={itemId} formKey={formKey} listId={listId} />
</React.Suspense> </React.Suspense>
) : ( ) : (
<div className={styles.state}>Для формы «{formKey}» режим согласования пока не настроен.</div> <div className={styles.state}>Для формы «{formKey}» режим согласования пока не настроен.</div>
@@ -4,6 +4,7 @@ import styles from '../approve/ApproveFeature.module.scss';
import { getFormComponent } from '../../forms/registry'; import { getFormComponent } from '../../forms/registry';
import { REQUEST_TYPES } from '../../data/requests'; import { REQUEST_TYPES } from '../../data/requests';
import { emitRequestsChanged } from '../../shared/requestEvents'; import { emitRequestsChanged } from '../../shared/requestEvents';
import { isConfirmedStaleListId } from '../../shared/approval';
export interface IEditFeatureProps { export interface IEditFeatureProps {
context: WebPartContext; context: WebPartContext;
@@ -11,6 +12,8 @@ export interface IEditFeatureProps {
formKey: string; formKey: string;
/** RowID редактируемой заявки из URL. */ /** RowID редактируемой заявки из URL. */
itemId: number; itemId: number;
/** Партиция, где лежит заявка (см. IRoute.listId) — не задана у ссылок старого формата. */
listId?: string;
/** Закрыть панель (после сохранения). */ /** Закрыть панель (после сохранения). */
onClose: () => void; onClose: () => void;
} }
@@ -22,7 +25,7 @@ export interface IEditFeatureProps {
// //
// Отдельно от features/submit намеренно: там форма живёт в потоке «плитки → // Отдельно от features/submit намеренно: там форма живёт в потоке «плитки →
// новая заявка», а тут — точечное открытие конкретной записи по ссылке. // новая заявка», а тут — точечное открытие конкретной записи по ссылке.
const EditFeature: React.FC<IEditFeatureProps> = ({ context, formKey, itemId, onClose }) => { const EditFeature: React.FC<IEditFeatureProps> = ({ context, formKey, itemId, listId, onClose }) => {
const FormComponent = getFormComponent(formKey); const FormComponent = getFormComponent(formKey);
const requestType = REQUEST_TYPES.filter((r) => r.id === formKey)[0]; const requestType = REQUEST_TYPES.filter((r) => r.id === formKey)[0];
@@ -30,6 +33,24 @@ const EditFeature: React.FC<IEditFeatureProps> = ({ context, formKey, itemId, on
return <div className={styles.state}>Для формы «{formKey}» редактирование не настроено.</div>; return <div className={styles.state}>Для формы «{formKey}» редактирование не настроено.</div>;
} }
// Партиция указана (заявки нумерованные, см. shared/approval/listPartitions.ts)
// и реестр ТОЧНО знает, что такого listId в серии типа нет (см.
// isConfirmedStaleListId) — устаревшая/битая ссылка. Если реестр про серию
// просто ничего не знает (approval-route не ответил при старте) — это НЕ
// повод отказывать: listId доверяем как есть, ровно как listTargetsForType в
// myRequests.service.ts. Проверяем ЗДЕСЬ, ДО монтирования формы: внутри неё
// имя списка резолвится синхронно (useMemo) и там уже негде красиво показать
// ошибку без риска уронить рендер всей панели — см. vacationApprovalService и
// аналоги в остальных формах.
if (listId && isConfirmedStaleListId(formKey, listId)) {
return (
<div className={styles.state}>
Не удалось открыть заявку: список «{listId}» не найден в реестре партиций. Ссылка могла устареть —
попробуйте открыть заявку заново из списка.
</div>
);
}
return ( return (
<div className={styles.content}> <div className={styles.content}>
<React.Suspense fallback={<div className={styles.state}>Загрузка формы…</div>}> <React.Suspense fallback={<div className={styles.state}>Загрузка формы…</div>}>
@@ -37,6 +58,7 @@ const EditFeature: React.FC<IEditFeatureProps> = ({ context, formKey, itemId, on
requestType={requestType} requestType={requestType}
context={context} context={context}
editId={itemId} editId={itemId}
editListId={listId}
onCancel={onClose} onCancel={onClose}
onSaved={() => { onSaved={() => {
emitRequestsChanged(); emitRequestsChanged();
@@ -16,6 +16,7 @@ import { IMyRequest } from './models/IMyRequest';
import { DataTable, IDataTableColumn } from '@tnsu/ui-kit'; import { DataTable, IDataTableColumn } from '@tnsu/ui-kit';
import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType, StatusBadge } from '../../shared/approval'; import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType, StatusBadge } from '../../shared/approval';
import { onRequestsChanged } from '../../shared/requestEvents'; import { onRequestsChanged } from '../../shared/requestEvents';
import { traced } from './perfTrace';
import { useRoutedQuery } from '../../shared/usePersistedQuery'; import { useRoutedQuery } from '../../shared/usePersistedQuery';
import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache'; import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
import { Pager } from './Pager'; import { Pager } from './Pager';
@@ -208,9 +209,13 @@ const Table: React.FC<{
// комментарий — по columnsFor (зависит от раздела и фильтра). Кастомные ячейки // комментарий — по columnsFor (зависит от раздела и фильтра). Кастомные ячейки
// (статус → StatusBadge, дата → формат) задаются через render. // (статус → StatusBadge, дата → формат) задаются через render.
const columns: IDataTableColumn<IMyRequest>[] = [ const columns: IDataTableColumn<IMyRequest>[] = [
{ key: 'id', title: 'ID', width: '70px' }, // Код заявки, а не Id элемента: Id уникален только внутри своей партиции
// (в каждом списке SharePoint нумерация с единицы), поэтому после архивации
// в таблице встречались разные заявки под одним номером. Кода нет у заявок,
// созданных до появления колонки — для них показываем Id, как раньше.
{ key: 'code', title: 'Код', width: '130px', render: (r) => r.code || `#${r.id}` },
{ key: 'type', title: 'Тип', width: '200px', render: (r) => r.requestTypeName }, { key: 'type', title: 'Тип', width: '200px', render: (r) => r.requestTypeName },
{ key: 'title', title: 'Название', render: (r) => r.title || `#${r.id}` }, { key: 'title', title: 'Название', render: (r) => r.title || r.code || `#${r.id}` },
{ key: 'date', title: 'Дата', width: '110px', render: (r) => formatDate(r.created) } { key: 'date', title: 'Дата', width: '110px', render: (r) => formatDate(r.created) }
]; ];
if (cols.author) { if (cols.author) {
@@ -392,16 +397,20 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
return result; return result;
}; };
const load = async (): Promise<void> => { const load = async (forceRender = false): Promise<void> => {
try { try {
const fresh = await fetchPage(); const fresh = await traced(`ИТОГО таблица:${view}#${page}`, fetchPage);
if (cancelled) return; if (cancelled) return;
// Перерисовываем, только если данные реально изменились — иначе при // Перерисовываем, только если данные реально изменились — иначе при
// каждом возврате на вкладку таблица моргала бы, перерисовываясь тем же // каждом возврате на вкладку таблица моргала бы, перерисовываясь тем же
// самым содержимым. // самым содержимым. forceRender — исключение: строки уже спрятаны со
// экрана (см. force ниже), и их обязательно нужно вернуть, даже если
// свежий ответ побайтово совпал с тем, что было в кеше, — иначе таблица
// так и останется в состоянии «Загрузка…», хотя данные давно пришли
// (баг: ушёл на этом самом совпадении сигнатур, найден 2026-08-18).
const prev = readPage(cacheKey); const prev = readPage(cacheKey);
if (!prev || pageSignature(prev) !== pageSignature(fresh)) { if (forceRender || !prev || pageSignature(prev) !== pageSignature(fresh)) {
writePage(cacheKey, fresh); writePage(cacheKey, fresh);
setRows(fresh.rows); setRows(fresh.rows);
setTotalRows(fresh.totalRows); setTotalRows(fresh.totalRows);
@@ -419,12 +428,12 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
const unsubscribe = onRequestsChanged((force) => { const unsubscribe = onRequestsChanged((force) => {
// Явный повторный клик по уже открытому пункту меню — пользователь ждёт // Явный повторный клик по уже открытому пункту меню — пользователь ждёт
// видимого обновления, а не молчаливой подмены данных за спиной. Прежние // видимого обновления, а не молчаливой подмены данных за спиной. Прежние
// строки прячем сразу; свежие подставит load(). // строки прячем сразу; свежие подставит load(force).
if (force) { if (force) {
setRows(undefined); setRows(undefined);
setLoadedKey(''); setLoadedKey('');
} }
load().catch(() => undefined); load(force).catch(() => undefined);
}); });
// Пока вкладка браузера скрыта, обновляться незачем — при возврате к ней // Пока вкладка браузера скрыта, обновляться незачем — при возврате к ней
// сработает ближайший тик. // сработает ближайший тик.
@@ -126,7 +126,9 @@
} }
.colId { .colId {
width: 70px; // Под код заявки («13-2608-42»), а не под Id элемента: прежних 70px на него
// не хватало (см. IMyRequest.code).
width: 130px;
color: $gray-400; color: $gray-400;
white-space: nowrap; white-space: nowrap;
} }
@@ -4,6 +4,7 @@ import { getLinkedTypes, hydratePendingAuthors, searchMyRequests } from './myReq
import { IMyRequest } from './models/IMyRequest'; import { IMyRequest } from './models/IMyRequest';
import { IRequestType, StatusBadge } from '../../shared/approval'; import { IRequestType, StatusBadge } from '../../shared/approval';
import { onRequestsChanged } from '../../shared/requestEvents'; import { onRequestsChanged } from '../../shared/requestEvents';
import { traced } from './perfTrace';
import { useRoutedQuery } from '../../shared/usePersistedQuery'; import { useRoutedQuery } from '../../shared/usePersistedQuery';
import { pageKey, pageSignature, readPage, writePage } from './pageCache'; import { pageKey, pageSignature, readPage, writePage } from './pageCache';
import { Pager } from './Pager'; import { Pager } from './Pager';
@@ -66,7 +67,7 @@ const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => voi
<table className={styles.table}> <table className={styles.table}>
<thead> <thead>
<tr> <tr>
<th className={styles.colId}>ID</th> <th className={styles.colId}>Код</th>
<th className={styles.colType}>Тип</th> <th className={styles.colType}>Тип</th>
<th className={styles.colTitle}>Название</th> <th className={styles.colTitle}>Название</th>
<th className={styles.colDate}>Дата</th> <th className={styles.colDate}>Дата</th>
@@ -91,9 +92,10 @@ const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => voi
} }
}} }}
> >
<td className={styles.colId}>{r.id}</td> {/* Код заявки, а не Id элемента — см. IMyRequest.code и колонки в RequestsTableView. */}
<td className={styles.colId}>{r.code || `#${r.id}`}</td>
<td className={styles.colType}>{r.requestTypeName}</td> <td className={styles.colType}>{r.requestTypeName}</td>
<td className={styles.colTitle}>{r.title || `#${r.id}`}</td> <td className={styles.colTitle}>{r.title || r.code || `#${r.id}`}</td>
<td className={styles.colDate}>{formatDate(r.created)}</td> <td className={styles.colDate}>{formatDate(r.created)}</td>
<td className={styles.colAuthor}> <td className={styles.colAuthor}>
{[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'} {[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'}
@@ -182,13 +184,16 @@ export const SearchView: React.FC<ISearchViewProps> = ({
setRows(cached ? cached.rows : undefined); setRows(cached ? cached.rows : undefined);
setTotalRows(cached ? cached.totalRows : 0); setTotalRows(cached ? cached.totalRows : 0);
const load = async (): Promise<void> => { const load = async (forceRender = false): Promise<void> => {
try { try {
const fresh = await searchMyRequests(context, types, debouncedQuery); const fresh = await traced('ИТОГО поиск', () => searchMyRequests(context, types, debouncedQuery));
if (cancelled) return; if (cancelled) return;
applyKnownAuthors(fresh.rows, authors.current); applyKnownAuthors(fresh.rows, authors.current);
const prev = readPage(cacheKey); const prev = readPage(cacheKey);
if (!prev || pageSignature(prev) !== pageSignature(fresh)) { // forceRender — строки уже спрятаны (см. force ниже) и обязаны
// вернуться, даже если свежий ответ совпал с прежним кешем: иначе
// экран так и останется в «Ищем…» (см. тот же баг в RequestsTableView).
if (forceRender || !prev || pageSignature(prev) !== pageSignature(fresh)) {
writePage(cacheKey, fresh); writePage(cacheKey, fresh);
setRows(fresh.rows); setRows(fresh.rows);
setTotalRows(fresh.totalRows); setTotalRows(fresh.totalRows);
@@ -202,7 +207,7 @@ export const SearchView: React.FC<ISearchViewProps> = ({
load().catch(() => undefined); load().catch(() => undefined);
const unsubscribe = onRequestsChanged((force) => { const unsubscribe = onRequestsChanged((force) => {
if (force) setRows(undefined); if (force) setRows(undefined);
load().catch(() => undefined); load(force).catch(() => undefined);
}); });
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
if (!document.hidden) load().catch(() => undefined); if (!document.hidden) load().catch(() => undefined);
@@ -2,6 +2,18 @@
// Собирается из списка данных SharePoint конкретного типа заявки (см. myRequests.service.ts). // Собирается из списка данных SharePoint конкретного типа заявки (см. myRequests.service.ts).
export interface IMyRequest { export interface IMyRequest {
id: number; id: number;
/**
* Уникальный код заявки (колонка RequestUniqueCode) — вида «13-2608-42», см.
* shared/requestCode.ts. Именно он показывается пользователю вместо Id
* элемента: Id уникален только ВНУТРИ своей партиции (в каждом списке
* SharePoint нумерация с единицы), поэтому после архивации в одной таблице
* оказывались разные заявки с одинаковым номером.
*
* Пустая строка — заявка создана до появления колонки либо код не успел
* записаться (он проставляется вторым запросом, уже после создания элемента,
* см. saveRequestItem): тогда в таблице показывается Id, как раньше.
*/
code: string;
title: string; title: string;
status: string; status: string;
authorEmail: string; authorEmail: string;
@@ -11,6 +23,20 @@ export interface IMyRequest {
requestTypeId: number; requestTypeId: number;
requestTypeName: string; requestTypeName: string;
section: string; section: string;
/**
* Список (партиция), из которого прочитана ИМЕННО эта заявка — серверный
* относительный url.
*
* Не выводится из requestTypeId: у типа заявки не один список, а нумерованная
* серия, и RequestType.listId указывает только на текущую партицию. Архивную
* заявку надо перечитывать (автор, комментарий решения, открытие формы) из её
* собственного списка — иначе элемент с таким Id там не найдётся или, хуже,
* найдётся ЧУЖАЯ заявка с тем же Id из другой партиции (нумерация элементов в
* каждом списке SharePoint своя).
*/
listId: string;
/** Сайт, где живёт listId этой заявки. */
siteUrl: string;
/** Ключ формы (rptype в URL режима согласования) — поле pageUrl из approval-route API. */ /** Ключ формы (rptype в URL режима согласования) — поле pageUrl из approval-route API. */
formKey: string; formKey: string;
/** 'own' — заявка, созданная текущим пользователем; 'approval' — ждёт его решения. */ /** 'own' — заявка, созданная текущим пользователем; 'approval' — ждёт его решения. */
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,7 @@ import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache'
function request(over: Partial<IMyRequest> = {}): IMyRequest { function request(over: Partial<IMyRequest> = {}): IMyRequest {
return { return {
id: 1, id: 1,
code: '1-2608-1',
title: 'Заявка №1', title: 'Заявка №1',
status: 'Черновик', status: 'Черновик',
authorEmail: 'ivanov@tnsu.kz', authorEmail: 'ivanov@tnsu.kz',
@@ -12,6 +13,8 @@ function request(over: Partial<IMyRequest> = {}): IMyRequest {
requestTypeId: 1, requestTypeId: 1,
requestTypeName: 'Приём на работу', requestTypeName: 'Приём на работу',
section: 'Кадры', section: 'Кадры',
listId: '/sites/requests/Lists/List1',
siteUrl: 'https://tnsukz.sharepoint.com/sites/requests',
formKey: 'hire', formKey: 'hire',
kind: 'own', kind: 'own',
...over, ...over,
@@ -111,6 +114,15 @@ describe('pageSignature', () => {
expect(pageSignature(before)).not.toBe(pageSignature(after)); expect(pageSignature(before)).not.toBe(pageSignature(after));
}); });
it('появление кода заявки меняет подпись — он показан колонкой «Код»', () => {
// Код проставляется вторым запросом, уже после создания элемента (см.
// saveRequestItem), поэтому первая выдача приходит без него. Не учитывать
// его в подписи — значит оставить в таблице «#1» до перезагрузки страницы.
const before = page({ rows: [request({ code: '' })] });
const after = page({ rows: [request({ code: '1-2608-1' })] });
expect(pageSignature(before)).not.toBe(pageSignature(after));
});
it('пустая страница имеет стабильную подпись', () => { it('пустая страница имеет стабильную подпись', () => {
expect(pageSignature({ rows: [], totalRows: 0 })).toBe(pageSignature({ rows: [], totalRows: 0 })); expect(pageSignature({ rows: [], totalRows: 0 })).toBe(pageSignature({ rows: [], totalRows: 0 }));
}); });
@@ -36,10 +36,15 @@ export function writePage(key: string, page: IPage): void {
// которые видны в таблице: если они совпали, перерисовывать нечего — иначе при // которые видны в таблице: если они совпали, перерисовывать нечего — иначе при
// каждом возврате на вкладку и при каждом фоновом обновлении таблица моргала бы, // каждом возврате на вкладку и при каждом фоновом обновлении таблица моргала бы,
// перерисовываясь тем же самым содержимым. // перерисовываясь тем же самым содержимым.
// code — тоже видимое поле (колонка «Код»), и его обязательно учитывать: он
// проставляется ВТОРЫМ запросом, уже после создания элемента (см.
// saveRequestItem), поэтому первая выдача может прийти без него. Без code в
// слепке подъехавший позже код совпал бы с прежней подписью, перерисовки не
// случилось бы, и в таблице до перезагрузки страницы висело бы «#5».
export function pageSignature(page: IPage): string { export function pageSignature(page: IPage): string {
return `${page.totalRows}|${page.rows return `${page.totalRows}|${page.rows
.map((r) => .map((r) =>
[r.requestTypeId, r.id, r.title, r.status, r.created, r.authorName, r.decisionComment ?? ''].join('~') [r.requestTypeId, r.id, r.code, r.title, r.status, r.created, r.authorName, r.decisionComment ?? ''].join('~')
) )
.join(';')}`; .join(';')}`;
} }
@@ -0,0 +1,21 @@
/**
* Замер времени отдельных этапов загрузки «Моих заявок» — что именно тормозит
* (индекс согласований, свои заявки по типу, конкретная партиция, Search),
* а не просто «страница долго грузится».
*
* ВРЕМЕННО: пишет всегда, без флага-переключателя — этот модуль и все traced()
* вокруг снимаются целиком, как только разбор текущей проблемы с
* производительностью закончится (см. myRequests.service.ts, RequestsTableView.tsx,
* SearchView.tsx).
*/
const PREFIX = '[Перф]';
/** Оборачивает async-шаг замером времени. */
export async function traced<T>(label: string, fn: () => Promise<T>): Promise<T> {
const start = performance.now();
try {
return await fn();
} finally {
console.log(`${PREFIX} ${label}: ${(performance.now() - start).toFixed(0)} мс`);
}
}
@@ -14,8 +14,12 @@ import { IRequestType } from '../../shared/approval';
* window.rpSearchTrace = true // следить за всеми типами * window.rpSearchTrace = true // следить за всеми типами
* window.rpSearchTrace = false // выключить * window.rpSearchTrace = false // выключить
* Значение сверяется с именем типа и с pageUrl (ключом формы), регистр не важен. * Значение сверяется с именем типа и с pageUrl (ключом формы), регистр не важен.
*
* По умолчанию выключена (false) — включённый DEFAULT_TRACE был точечной
* настройкой на время разбора конкретного бага с партициями «Обходного листа»
* и не должен постоянно писать в консоль всем остальным.
*/ */
const DEFAULT_TRACE: string | boolean = 'обходной'; const DEFAULT_TRACE: string | boolean = false;
const PREFIX = '[Поиск]'; const PREFIX = '[Поиск]';
@@ -16,22 +16,35 @@ import {
StatusBadge, StatusBadge,
useMatrixEditor, useMatrixEditor,
getPlannedMatrix, getPlannedMatrix,
resolveWriteListId,
notifyActiveStep, notifyActiveStep,
useRequestTypeId, useRequestTypeId,
REQUEST_STATUS, REQUEST_STATUS,
isEditableStatus, isEditableStatus,
IApproverToWrite, IApproverToWrite,
APPROVAL_PHASE, APPROVAL_PHASE,
findUnassignedStep unassignedStepMessage
} from '../../shared/approval'; } from '../../shared/approval';
import { IRequestFormProps } from '../../models/types'; import { IRequestFormProps } from '../../models/types';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { saveClearanceRequest, clearanceApprovalService } from './submit'; import { saveClearanceRequest, clearanceApprovalService } from './submit';
import { loadClearanceRequest } from './load'; import { loadClearanceRequest } from './load';
import { RequestSection } from './sections/RequestSection'; import { RequestSection } from './sections/RequestSection';
const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, editId }) => { const ClearanceForm: React.FC<IRequestFormProps> = ({
requestType,
context,
onCancel,
onSaved,
editId,
editListId
}) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [requestId, setRequestId] = useState<number | undefined>(editId); const [requestId, setRequestId] = useState<number | undefined>(editId);
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined); const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
@@ -41,7 +54,10 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
const readOnly = !isEditableStatus(requestStatus); const readOnly = !isEditableStatus(requestStatus);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
const approval = useMemo(() => clearanceApprovalService(), []); // editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
// clearanceApprovalService можно звать не опасаясь исключения.
const approval = useMemo(() => clearanceApprovalService(editListId), [editListId]);
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API. // data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
const requestTypeIdState = useRequestTypeId(context, requestType.id); const requestTypeIdState = useRequestTypeId(context, requestType.id);
@@ -53,7 +69,7 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
return; return;
} }
let alive = true; let alive = true;
loadClearanceRequest(editId) loadClearanceRequest(editId, editListId)
.then((loaded) => { .then((loaded) => {
if (alive) { if (alive) {
setData(loaded.data); setData(loaded.data);
@@ -70,7 +86,7 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
alive = false; alive = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [editId]); }, [editId, editListId]);
// Новая заявка — дата расторжения по умолчанию сегодняшняя (типичный случай: // Новая заявка — дата расторжения по умолчанию сегодняшняя (типичный случай:
// обходной лист оформляют в день увольнения). При открытии на редактирование // обходной лист оформляют в день увольнения). При открытии на редактирование
@@ -174,13 +190,22 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
if (!rtId) { if (!rtId) {
throw new Error('Тип заявки не определён — обновите страницу и повторите.'); throw new Error('Тип заявки не определён — обновите страницу и повторите.');
} }
// requestId уже задан → редактируем существующую заявку, пишем в ту же
// партицию (editListId). Иначе — создаём новую, и партицию для записи
// спрашиваем у approval-route ПРЯМО СЕЙЧАС (а не берём из кеша onInit):
// это единственный запрос, который проверяет порог элементов текущего
// списка и ставит в очередь создание следующей партиции при достижении —
// без него архивация никогда не сработает. Сбой запроса не должен
// блокировать подачу заявки — откатываемся на список по конвенции имён.
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
const { id, code } = await saveClearanceRequest( const { id, code } = await saveClearanceRequest(
requestId, requestId,
rtId, rtId,
data, data,
toApprovers(matrix.rows), toApprovers(matrix.rows),
toApprovers(executors.rows), toApprovers(executors.rows),
status status,
writeListId
); );
setRequestId(id); setRequestId(id);
setRequestStatus(status); setRequestStatus(status);
@@ -191,11 +216,14 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
onSaved(); onSaved();
} }
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения). // Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
// Имена списков — той же партиции, куда фактически записали (writeListId),
// не обязательно текущей на момент рендера формы: поток ищет строку
// маршрута именно в НЕЙ.
if (status === REQUEST_STATUS.onApproval) { if (status === REQUEST_STATUS.onApproval) {
await notifyActiveStep(context, approval, id, { await notifyActiveStep(context, approval, id, {
requestTypeId: rtId, requestTypeId: rtId,
dataListName: dataListTitle(FORM_ID), dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID) approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
}); });
} }
}; };
@@ -208,14 +236,16 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
if (matrix.error) { if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`; return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
} }
const unassigned = findUnassignedStep(matrix.rows); const unassignedApproverError = unassignedStepMessage(
if (unassigned) { matrix.rows,
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`; (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
} );
const unassignedExecutor = findUnassignedStep(executors.rows); if (unassignedApproverError) return unassignedApproverError;
if (unassignedExecutor) { const unassignedExecutorError = unassignedStepMessage(
return `Шаг ${unassignedExecutor.order} исполнения («${unassignedExecutor.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`; executors.rows,
} (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`
);
if (unassignedExecutorError) return unassignedExecutorError;
return undefined; return undefined;
}; };
@@ -234,7 +264,12 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
<FormCard <FormCard
title={requestType.title} title={requestType.title}
subtitle="Категория: Отдел кадров" subtitle="Категория: Отдел кадров"
belowSubtitle={<InitiatorLine listTitle={dataListTitle(FORM_ID)} itemId={requestId} />} belowSubtitle={
<InitiatorLine
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
itemId={requestId}
/>
}
headerRight={ headerRight={
requestId || requestStatus ? ( requestId || requestStatus ? (
<> <>
@@ -13,7 +13,12 @@ import {
useRequestTypeId, useRequestTypeId,
APPROVAL_PHASE APPROVAL_PHASE
} from '../../shared/approval'; } from '../../shared/approval';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadClearanceRequest } from './load'; import { loadClearanceRequest } from './load';
import { import {
@@ -29,6 +34,8 @@ interface IClearanceViewProps {
itemId: number; itemId: number;
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */ /** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
formKey: string; formKey: string;
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
listId?: string;
} }
type SaveState = 'idle' | 'saving' | 'saved' | 'error'; type SaveState = 'idle' | 'saving' | 'saved' | 'error';
@@ -36,7 +43,7 @@ type SaveState = 'idle' | 'saving' | 'saved' | 'error';
// Обходной лист в режиме согласующего. Форма read-only, КРОМЕ отметок, которые // Обходной лист в режиме согласующего. Форма read-only, КРОМЕ отметок, которые
// заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения, // заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения,
// отдел подбора — парковочная карта (см. approverRights.ts). // отдел подбора — парковочная карта (см. approverRights.ts).
const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey }) => { const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey, listId }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
@@ -48,12 +55,14 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
const approval = useMemo(() => clearanceApprovalService(), []); // listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
// clearanceApprovalService можно звать не опасаясь исключения.
const approval = useMemo(() => clearanceApprovalService(listId), [listId]);
const { id: requestTypeId } = useRequestTypeId(context, formKey); const { id: requestTypeId } = useRequestTypeId(context, formKey);
const approverService = useMemo( // Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
() => new ApproverService(approval, dataListTitle(FORM_ID)), // архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
[approval] const dataListRef = listId || dataListTitle(FORM_ID);
); const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
// Матрица маршрута из фактических строк заявки (read-only), раздельно по // Матрица маршрута из фактических строк заявки (read-only), раздельно по
// фазам: согласование и исполнение — два отдельных блока в UI (см. ниже). // фазам: согласование и исполнение — два отдельных блока в UI (см. ниже).
@@ -84,7 +93,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
setLoading(true); setLoading(true);
loadClearanceRequest(itemId) loadClearanceRequest(itemId, listId)
.then((loaded) => { .then((loaded) => {
if (alive) { if (alive) {
setData(loaded.data); setData(loaded.data);
@@ -103,7 +112,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
return () => { return () => {
alive = false; alive = false;
}; };
}, [itemId]); }, [itemId, listId]);
// Права на отметки — по должности ТЕКУЩЕГО активного шага пользователя. // Права на отметки — по должности ТЕКУЩЕГО активного шага пользователя.
// isDelegator намеренно не даёт прав: делегировавший не решает за делегата // isDelegator намеренно не даёт прав: делегировавший не решает за делегата
@@ -141,8 +150,8 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
if (parkingEditable) { if (parkingEditable) {
fields.ParkingCard = data.ParkingCard; fields.ParkingCard = data.ParkingCard;
} }
return updateApproverFields(itemId, fields); return updateApproverFields(itemId, fields, listId);
}, [itemId, hrRecordsEditable, parkingEditable, data.AccessCard, data.TrainingAmount, data.ParkingCard]); }, [itemId, listId, hrRecordsEditable, parkingEditable, data.AccessCard, data.TrainingAmount, data.ParkingCard]);
const handleSaveFields = async (): Promise<void> => { const handleSaveFields = async (): Promise<void> => {
setSaveState('saving'); setSaveState('saving');
@@ -175,8 +184,8 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
if (!requestTypeId) return; if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, { notifyActiveStep(context, approval, itemId, {
requestTypeId, requestTypeId,
dataListName: dataListTitle(FORM_ID), dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID) approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined); }).catch(() => undefined);
}; };
@@ -205,7 +214,12 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
<FormCard <FormCard
title="Обходной лист" title="Обходной лист"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={<InitiatorLine listTitle={dataListTitle(FORM_ID)} itemId={itemId} />} belowSubtitle={
<InitiatorLine
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
itemId={itemId}
/>
}
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={status ? <StatusBadge status={status} /> : undefined}
> >
<FormModeProvider readOnly> <FormModeProvider readOnly>
@@ -11,10 +11,17 @@ export interface ILoadedRequest {
code: string; code: string;
} }
/** Прочитать обходной лист по id: модель формы + статус (правка / просмотр). */ /**
export async function loadClearanceRequest(itemId: number): Promise<ILoadedRequest> { * Прочитать обходной лист по id: модель формы + статус (правка / просмотр).
const item = (await getSP() *
.web.lists.getByTitle(dataListTitle(FORM_ID)) * listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/
* listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции (как раньше); задан —
* идём напрямую по url списка, а не по имени: так не нужно резолвить, как
* называется архивный список, и запрос не зависит от реестра партиций.
*/
export async function loadClearanceRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = (await list
.items.getById(itemId) .items.getById(itemId)
.select( .select(
'Id', 'Id',
@@ -1,12 +1,15 @@
import { getSP } from '../../shared/pnp/pnpjs'; import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval'; import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList';
import { buildRequestUniqueCode } from '../../shared/requestCode'; import { ensureEmployee } from '../../shared/employee';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData } from './model'; import { FORM_ID, IFormData } from './model';
/** Экземпляр сервиса списка согласования этой формы. */ /**
export function clearanceApprovalService(): ApprovalListService { * Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
return new ApprovalListService(approvalListTitle(FORM_ID)); * approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
* именем формы для читаемости вызывающего кода.
*/
export function clearanceApprovalService(listId?: string): ApprovalListService {
return approvalServiceFor(FORM_ID, listId);
} }
/** Отметки, которые проставляет не инициатор, а профильный согласующий (см. approverRights.ts). */ /** Отметки, которые проставляет не инициатор, а профильный согласующий (см. approverRights.ts). */
@@ -20,10 +23,14 @@ export interface IApproverEditableFields {
* Обновить только те отметки, что заполняет согласующий на своём шаге. * Обновить только те отметки, что заполняет согласующий на своём шаге.
* Пишутся ровно переданные поля: у каждого отдела свой набор прав, и лишние * Пишутся ровно переданные поля: у каждого отдела свой набор прав, и лишние
* ключи затёрли бы чужие отметки значениями из формы этого согласующего. * ключи затёрли бы чужие отметки значениями из формы этого согласующего.
*
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadClearanceRequest); не задан —
* текущая партиция, как раньше.
*/ */
export async function updateApproverFields( export async function updateApproverFields(
id: number, id: number,
fields: Partial<IApproverEditableFields> fields: Partial<IApproverEditableFields>,
listId?: string
): Promise<void> { ): Promise<void> {
const item: Record<string, unknown> = {}; const item: Record<string, unknown> = {};
if (fields.AccessCard !== undefined) item.AccessCard = fields.AccessCard; if (fields.AccessCard !== undefined) item.AccessCard = fields.AccessCard;
@@ -34,13 +41,8 @@ export async function updateApproverFields(
Number.isFinite(amount) && fields.TrainingAmount.trim() !== '' ? amount : null; Number.isFinite(amount) && fields.TrainingAmount.trim() !== '' ? amount : null;
} }
if (Object.keys(item).length === 0) return; if (Object.keys(item).length === 0) return;
await getSP().web.lists.getByTitle(dataListTitle(FORM_ID)).items.getById(id).update(item); const list = resolveDataList(FORM_ID, listId);
} await list.items.getById(id).update(item);
/** email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка). */
async function ensureEmployee(login: string): Promise<{ Id: number; Title: string }> {
const full = login.indexOf('|') >= 0 ? login : `i:0#.f|membership|${login}`;
return getSP().web.ensureUser(full);
} }
function pad2(n: number): string { function pad2(n: number): string {
@@ -69,16 +71,20 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
}; };
} }
export interface ISaveResult { export type ISaveResult = ISaveItemResult;
id: number;
/** Задан только при создании новой заявки (существующий код не меняется). */
code?: string;
}
/** /**
* Сохранить обходной лист: содержимое + маршрут (согласование + исполнение). * Сохранить обходной лист: содержимое + маршрут (согласование + исполнение).
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Возвращает Id заявки (+ код при создании). * Возвращает Id заявки (+ код при создании).
*
* listId — партиция, в которую фактически пишем: при редактировании — партиция
* ОБНОВЛЯЕМОЙ заявки (см. loadClearanceRequest), при создании — партиция,
* полученная от approval-route API непосредственно перед вызовом (см.
* getCurrentListPartition в ClearanceForm.tsx) — именно этот вызов, а не кеш из
* onInit, проверяет порог элементов и запускает архивацию следующей партиции.
* Не задан (сбой запроса за текущей партицией) → откат на список по конвенции
* имён, как до появления партиционирования.
*/ */
export async function saveClearanceRequest( export async function saveClearanceRequest(
existingId: number | undefined, existingId: number | undefined,
@@ -86,25 +92,13 @@ export async function saveClearanceRequest(
data: IFormData, data: IFormData,
approvers: IApproverToWrite[], approvers: IApproverToWrite[],
executors: IApproverToWrite[], executors: IApproverToWrite[],
status: string status: string,
listId?: string
): Promise<ISaveResult> { ): Promise<ISaveResult> {
const list = getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const employee = await ensureEmployee(data.Employee); const employee = await ensureEmployee(data.Employee);
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee); const item = buildItem(data, status, employee.Id, employee.Title || data.Employee);
let id: number; const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
let code: string | undefined; await writeRoute(clearanceApprovalService(listId), result.id, approvers, executors, status);
if (existingId) { return result;
await list.items.getById(existingId).update(item);
id = existingId;
} else {
const added = await list.items.add(item);
id = (added as { Id: number }).Id;
// Код известен только после создания элемента (нужен его Id).
code = buildRequestUniqueCode(requestTypeId, id, new Date());
await list.items.getById(id).update({ RequestUniqueCode: code });
}
await writeRoute(clearanceApprovalService(), id, approvers, executors, status);
return { id, code };
} }
@@ -20,13 +20,14 @@ import {
StatusBadge, StatusBadge,
useMatrixEditor, useMatrixEditor,
getPlannedMatrix, getPlannedMatrix,
resolveWriteListId,
notifyActiveStep, notifyActiveStep,
useRequestTypeId, useRequestTypeId,
REQUEST_STATUS, REQUEST_STATUS,
isEditableStatus, isEditableStatus,
IApproverToWrite, IApproverToWrite,
APPROVAL_PHASE, APPROVAL_PHASE,
findUnassignedStep unassignedStepMessage
} from '../../shared/approval'; } from '../../shared/approval';
import { import {
uploadAttachments, uploadAttachments,
@@ -36,7 +37,12 @@ import {
} from '../../shared/attachments/attachments.service'; } from '../../shared/attachments/attachments.service';
import { effectiveUserEmail } from '../../shared/impersonation'; import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types'; import { IRequestFormProps } from '../../models/types';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { saveHireRequest, hireApprovalService } from './submit'; import { saveHireRequest, hireApprovalService } from './submit';
import { loadHireRequest } from './load'; import { loadHireRequest } from './load';
@@ -45,7 +51,14 @@ import { CandidateSection } from './sections/CandidateSection';
import { PpeSection } from './sections/PpeSection'; import { PpeSection } from './sections/PpeSection';
import { ConditionsSection } from './sections/ConditionsSection'; import { ConditionsSection } from './sections/ConditionsSection';
const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, editId }) => { const RecruitingHireForm: React.FC<IRequestFormProps> = ({
requestType,
context,
onCancel,
onSaved,
editId,
editListId
}) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]); const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]);
@@ -67,7 +80,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
return; return;
} }
let alive = true; let alive = true;
Promise.all([loadHireRequest(editId), getAttachments(dataListTitle(FORM_ID), editId)]) Promise.all([loadHireRequest(editId, editListId), getAttachments(editListId || dataListTitle(FORM_ID), editId)])
.then(([loaded, atts]) => { .then(([loaded, atts]) => {
if (alive) { if (alive) {
setData(loaded.data); setData(loaded.data);
@@ -84,13 +97,16 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
return () => { return () => {
alive = false; alive = false;
}; };
}, [editId]); }, [editId, editListId]);
// Маршрут согласования (планируемый) резолвится по email инициатора — текущего // Маршрут согласования (планируемый) резолвится по email инициатора — текущего
// пользователя (кандидата ещё нет в системе). У заявки, открытой на // пользователя (кандидата ещё нет в системе). У заявки, открытой на
// редактирование, маршрут читается из уже записанных строк — планирование // редактирование, маршрут читается из уже записанных строк — планирование
// затёрло бы правки, сделанные при первом сохранении. // затёрло бы правки, сделанные при первом сохранении.
const approval = useMemo(() => hireApprovalService(), []); // editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
// hireApprovalService можно звать не опасаясь исключения.
const approval = useMemo(() => hireApprovalService(editListId), [editListId]);
const userEmail = effectiveUserEmail(context); const userEmail = effectiveUserEmail(context);
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API. // data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
@@ -158,7 +174,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
if (requestId === undefined) { if (requestId === undefined) {
return; return;
} }
deleteAttachment(dataListTitle(FORM_ID), requestId, fileName) deleteAttachment(editListId || dataListTitle(FORM_ID), requestId, fileName)
.then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName))) .then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName)))
.catch(() => undefined); .catch(() => undefined);
}; };
@@ -171,13 +187,22 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
if (!rtId) { if (!rtId) {
throw new Error('Тип заявки не определён — обновите страницу и повторите.'); throw new Error('Тип заявки не определён — обновите страницу и повторите.');
} }
// requestId уже задан → редактируем существующую заявку, пишем в ту же
// партицию (editListId). Иначе — создаём новую, и партицию для записи
// спрашиваем у approval-route ПРЯМО СЕЙЧАС (а не берём из кеша onInit):
// это единственный запрос, который проверяет порог элементов текущего
// списка и ставит в очередь создание следующей партиции при достижении —
// без него архивация никогда не сработает. Сбой запроса не должен
// блокировать подачу заявки — откатываемся на список по конвенции имён.
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
const { id, code } = await saveHireRequest( const { id, code } = await saveHireRequest(
requestId, requestId,
rtId, rtId,
data, data,
toApprovers(matrix.rows), toApprovers(matrix.rows),
toApprovers(executors.rows), toApprovers(executors.rows),
status status,
writeListId
); );
setRequestId(id); setRequestId(id);
setRequestStatus(status); setRequestStatus(status);
@@ -185,19 +210,22 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
setRequestCode(code); setRequestCode(code);
} }
if (files.length > 0) { if (files.length > 0) {
await uploadAttachments(dataListTitle(FORM_ID), id, files); // файлы — после создания элемента await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files); // файлы — после создания элемента
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
setExistingAttachments(await getAttachments(dataListTitle(FORM_ID), id)); // показать добавленные setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id)); // показать добавленные
} }
if (onSaved) { if (onSaved) {
onSaved(); onSaved();
} }
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения). // Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
// Имена списков — той же партиции, куда фактически записали (writeListId),
// не обязательно текущей на момент рендера формы: поток ищет строку
// маршрута именно в НЕЙ.
if (status === REQUEST_STATUS.onApproval) { if (status === REQUEST_STATUS.onApproval) {
await notifyActiveStep(context, approval, id, { await notifyActiveStep(context, approval, id, {
requestTypeId: rtId, requestTypeId: rtId,
dataListName: dataListTitle(FORM_ID), dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID), approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID),
}); });
} }
}; };
@@ -209,14 +237,16 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
const validateSubmit = (): string | undefined => { const validateSubmit = (): string | undefined => {
const formError = validate(); const formError = validate();
if (formError) return formError; if (formError) return formError;
const unassigned = findUnassignedStep(matrix.rows); const unassignedApproverError = unassignedStepMessage(
if (unassigned) { matrix.rows,
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`; (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
} );
const unassignedExecutor = findUnassignedStep(executors.rows); if (unassignedApproverError) return unassignedApproverError;
if (unassignedExecutor) { const unassignedExecutorError = unassignedStepMessage(
return `Шаг ${unassignedExecutor.order} исполнения («${unassignedExecutor.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`; executors.rows,
} (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`
);
if (unassignedExecutorError) return unassignedExecutorError;
return undefined; return undefined;
}; };
@@ -237,7 +267,12 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
<FormCard <FormCard
title={requestType.title} title={requestType.title}
subtitle="Категория: Отдел рекрутинга" subtitle="Категория: Отдел рекрутинга"
belowSubtitle={<InitiatorLine listTitle={dataListTitle(FORM_ID)} itemId={requestId} />} belowSubtitle={
<InitiatorLine
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
itemId={requestId}
/>
}
headerRight={ headerRight={
requestId || requestStatus ? ( requestId || requestStatus ? (
<> <>
@@ -15,7 +15,12 @@ import {
} from '../../shared/approval'; } from '../../shared/approval';
import { navigate } from '../../shared/routing'; import { navigate } from '../../shared/routing';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadHireRequest } from './load'; import { loadHireRequest } from './load';
import { confirmDiscardFormChanges } from '../../shared/unsavedChanges'; import { confirmDiscardFormChanges } from '../../shared/unsavedChanges';
@@ -30,6 +35,8 @@ interface IRecruitingHireViewProps {
itemId: number; itemId: number;
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */ /** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
formKey: string; formKey: string;
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
listId?: string;
} }
// Ключ формы «Организация рабочего места» в реестре (data/requests) — цель // Ключ формы «Организация рабочего места» в реестре (data/requests) — цель
@@ -42,7 +49,7 @@ const NOOP_SET: SetField = () => undefined;
// Заявка «на прием» только для просмотра (режим согласующего). // Заявка «на прием» только для просмотра (режим согласующего).
// Переиспользует те же секции, что и форма подачи; редактирование выключено // Переиспользует те же секции, что и форма подачи; редактирование выключено
// через <FormModeProvider readOnly> — поля сами становятся неизменяемыми. // через <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemId, formKey }) => { const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemId, formKey, listId }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
@@ -52,10 +59,16 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
const { id: requestTypeId } = useRequestTypeId(context, formKey); const { id: requestTypeId } = useRequestTypeId(context, formKey);
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
// hireApprovalService можно звать не опасаясь исключения.
//
// Сервис решений согласующего: список согласования + список данных этой формы. // Сервис решений согласующего: список согласования + список данных этой формы.
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
const dataListRef = listId || dataListTitle(FORM_ID);
const approverService = useMemo( const approverService = useMemo(
() => new ApproverService(hireApprovalService(), dataListTitle(FORM_ID)), () => new ApproverService(hireApprovalService(listId), dataListRef),
[] [listId, dataListRef]
); );
// Матрица маршрута из фактических строк заявки (read-only), разбитая по фазам. // Матрица маршрута из фактических строк заявки (read-only), разбитая по фазам.
@@ -66,7 +79,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
// перечитывает матрицу. Иначе на экране остались бы данные, загруженные при // перечитывает матрицу. Иначе на экране остались бы данные, загруженные при
// открытии формы, и пользователь видел бы свой шаг всё ещё «На согласовании». // открытии формы, и пользователь видел бы свой шаг всё ещё «На согласовании».
const [decisionSeq, setDecisionSeq] = useState<number>(0); const [decisionSeq, setDecisionSeq] = useState<number>(0);
const approval = useMemo(() => hireApprovalService(), []); const approval = useMemo(() => hireApprovalService(listId), [listId]);
const loadApprovers = useCallback( const loadApprovers = useCallback(
() => () =>
approval approval
@@ -91,7 +104,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
setLoading(true); setLoading(true);
Promise.all([loadHireRequest(itemId), getAttachments(dataListTitle(FORM_ID), itemId)]) Promise.all([loadHireRequest(itemId, listId), getAttachments(listId || dataListTitle(FORM_ID), itemId)])
.then(([loaded, files]) => { .then(([loaded, files]) => {
if (alive) { if (alive) {
setData(loaded.data); setData(loaded.data);
@@ -111,7 +124,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
return () => { return () => {
alive = false; alive = false;
}; };
}, [itemId]); }, [itemId, listId]);
if (loading) { if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>; return <div className={styles.state}>Загрузка заявки…</div>;
@@ -130,8 +143,8 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
if (!requestTypeId) return; if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, { notifyActiveStep(context, approval, itemId, {
requestTypeId, requestTypeId,
dataListName: dataListTitle(FORM_ID), dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID), approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
}).catch(() => undefined); }).catch(() => undefined);
}; };
@@ -160,7 +173,12 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
<FormCard <FormCard
title="Заявка на прием" title="Заявка на прием"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={<InitiatorLine listTitle={dataListTitle(FORM_ID)} itemId={itemId} />} belowSubtitle={
<InitiatorLine
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
itemId={itemId}
/>
}
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={status ? <StatusBadge status={status} /> : undefined}
> >
<FormModeProvider readOnly> <FormModeProvider readOnly>
@@ -16,11 +16,18 @@ export interface ILoadedRequest {
code: string; code: string;
} }
/** Прочитать заявку «на прием» по id: модель формы + статус (для режима чтения). */ /**
export async function loadHireRequest(itemId: number): Promise<ILoadedRequest> { * Прочитать заявку «на прием» по id: модель формы + статус (для режима чтения).
*
* listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/
* listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции (как раньше); задан —
* идём напрямую по url списка, а не по имени: так не нужно резолвить, как
* называется архивный список, и запрос не зависит от реестра партиций.
*/
export async function loadHireRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
const sp = getSP(); const sp = getSP();
const item = (await sp.web.lists const list = listId ? sp.web.getList(listId) : sp.web.lists.getByTitle(dataListTitle(FORM_ID));
.getByTitle(dataListTitle(FORM_ID)) const item = (await list
.items.getById(itemId) .items.getById(itemId)
.select(...SCALAR_FIELDS, 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>; .select(...SCALAR_FIELDS, 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>;
@@ -1,15 +1,17 @@
import { getSP } from '../../shared/pnp/pnpjs'; import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval'; import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
import { buildRequestUniqueCode } from '../../shared/requestCode';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData } from './model'; import { FORM_ID, IFormData } from './model';
// Числовые поля: пустая строка → null, иначе Number(). // Числовые поля: пустая строка → null, иначе Number().
const NUMBER_FIELDS: (keyof IFormData)[] = ['PpeHeight', 'PpeShoeSize', 'SalaryProbation', 'SalaryAfterProbation']; const NUMBER_FIELDS: (keyof IFormData)[] = ['PpeHeight', 'PpeShoeSize', 'SalaryProbation', 'SalaryAfterProbation'];
/** Экземпляр сервиса списка согласования этой формы. */ /**
export function hireApprovalService(): ApprovalListService { * Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
return new ApprovalListService(approvalListTitle(FORM_ID)); * approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
* именем формы для читаемости вызывающего кода.
*/
export function hireApprovalService(listId?: string): ApprovalListService {
return approvalServiceFor(FORM_ID, listId);
} }
/** Осмысленный заголовок элемента списка (системная колонка Title). */ /** Осмысленный заголовок элемента списка (системная колонка Title). */
@@ -33,11 +35,7 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
return item; return item;
} }
export interface ISaveResult { export type ISaveResult = ISaveItemResult;
id: number;
/** Задан только при создании новой заявки (существующий код не меняется). */
code?: string;
}
/** /**
* Сохранить заявку «на прием»: контент + маршрут (согласующие + исполнители) со статусом. * Сохранить заявку «на прием»: контент + маршрут (согласующие + исполнители) со статусом.
@@ -45,6 +43,14 @@ export interface ISaveResult {
* status = «Черновик» (сохранение) или «На согласовании» (отправка). * status = «Черновик» (сохранение) или «На согласовании» (отправка).
* executors может быть пустым — тогда после согласования заявка станет «Согласовано». * executors может быть пустым — тогда после согласования заявка станет «Согласовано».
* Возвращает Id заявки (+ код при создании). * Возвращает Id заявки (+ код при создании).
*
* listId — партиция, в которую фактически пишем: при редактировании — партиция
* ОБНОВЛЯЕМОЙ заявки (см. loadHireRequest), при создании — партиция, полученная
* от approval-route API непосредственно перед вызовом (см. getCurrentListPartition
* в RecruitingHireForm.tsx) — именно этот вызов, а не кеш из onInit, проверяет
* порог элементов и запускает архивацию следующей партиции.
* Не задан (сбой запроса за текущей партицией) → откат на список по конвенции
* имён, как до появления партиционирования.
*/ */
export async function saveHireRequest( export async function saveHireRequest(
existingId: number | undefined, existingId: number | undefined,
@@ -52,24 +58,12 @@ export async function saveHireRequest(
data: IFormData, data: IFormData,
approvers: IApproverToWrite[], approvers: IApproverToWrite[],
executors: IApproverToWrite[], executors: IApproverToWrite[],
status: string status: string,
listId?: string
): Promise<ISaveResult> { ): Promise<ISaveResult> {
const list = getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = buildItem(data, status); const item = buildItem(data, status);
let id: number; const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
let code: string | undefined; await writeRoute(hireApprovalService(listId), result.id, approvers, executors, status);
if (existingId) { return result;
await list.items.getById(existingId).update(item);
id = existingId;
} else {
const added = await list.items.add(item);
id = (added as { Id: number }).Id;
// Код известен только после создания элемента (нужен его Id).
code = buildRequestUniqueCode(requestTypeId, id, new Date());
await list.items.getById(id).update({ RequestUniqueCode: code });
}
await writeRoute(hireApprovalService(), id, approvers, executors, status);
return { id, code };
} }
@@ -20,12 +20,13 @@ import {
StatusBadge, StatusBadge,
useMatrixEditor, useMatrixEditor,
getPlannedMatrix, getPlannedMatrix,
resolveWriteListId,
notifyActiveStep, notifyActiveStep,
useRequestTypeId, useRequestTypeId,
REQUEST_STATUS, REQUEST_STATUS,
isEditableStatus, isEditableStatus,
IApproverToWrite, IApproverToWrite,
findUnassignedStep unassignedStepMessage
} from '../../shared/approval'; } from '../../shared/approval';
import { import {
uploadAttachments, uploadAttachments,
@@ -35,7 +36,12 @@ import {
} from '../../shared/attachments/attachments.service'; } from '../../shared/attachments/attachments.service';
import { effectiveUserEmail } from '../../shared/impersonation'; import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types'; import { IRequestFormProps } from '../../models/types';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { saveSelectionRequest, selectionApprovalService } from './submit'; import { saveSelectionRequest, selectionApprovalService } from './submit';
import { loadSelectionRequest } from './load'; import { loadSelectionRequest } from './load';
@@ -45,7 +51,14 @@ import { VacancySection } from './sections/VacancySection';
import { HealthTripsSection } from './sections/HealthTripsSection'; import { HealthTripsSection } from './sections/HealthTripsSection';
import { ConditionsSection } from './sections/ConditionsSection'; import { ConditionsSection } from './sections/ConditionsSection';
const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, editId }) => { const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
requestType,
context,
onCancel,
onSaved,
editId,
editListId
}) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]); const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]);
@@ -64,7 +77,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ requestType, con
return; return;
} }
let alive = true; let alive = true;
Promise.all([loadSelectionRequest(editId), getAttachments(dataListTitle(FORM_ID), editId)]) Promise.all([loadSelectionRequest(editId, editListId), getAttachments(editListId || dataListTitle(FORM_ID), editId)])
.then(([loaded, atts]) => { .then(([loaded, atts]) => {
if (alive) { if (alive) {
setData(loaded.data); setData(loaded.data);
@@ -82,12 +95,15 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ requestType, con
alive = false; alive = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [editId]); }, [editId, editListId]);
// Маршрут согласования резолвится по email инициатора (текущего пользователя). // Маршрут согласования резолвится по email инициатора (текущего пользователя).
// У заявки на редактировании маршрут читается из уже записанных строк, чтобы не // У заявки на редактировании маршрут читается из уже записанных строк, чтобы не
// затереть правки инициатора при первом сохранении. // затереть правки инициатора при первом сохранении.
const approval = useMemo(() => selectionApprovalService(), []); // editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
// selectionApprovalService можно звать не опасаясь исключения.
const approval = useMemo(() => selectionApprovalService(editListId), [editListId]);
const userEmail = effectiveUserEmail(context); const userEmail = effectiveUserEmail(context);
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API. // data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
@@ -132,7 +148,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ requestType, con
if (requestId === undefined) { if (requestId === undefined) {
return; return;
} }
deleteAttachment(dataListTitle(FORM_ID), requestId, fileName) deleteAttachment(editListId || dataListTitle(FORM_ID), requestId, fileName)
.then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName))) .then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName)))
.catch(() => undefined); .catch(() => undefined);
}; };
@@ -145,26 +161,44 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ requestType, con
if (!rtId) { if (!rtId) {
throw new Error('Тип заявки не определён — обновите страницу и повторите.'); throw new Error('Тип заявки не определён — обновите страницу и повторите.');
} }
const { id, code } = await saveSelectionRequest(requestId, rtId, data, toApprovers(matrix.rows), status); // requestId уже задан → редактируем существующую заявку, пишем в ту же
// партицию (editListId). Иначе — создаём новую, и партицию для записи
// спрашиваем у approval-route ПРЯМО СЕЙЧАС (а не берём из кеша onInit):
// это единственный запрос, который проверяет порог элементов текущего
// списка и ставит в очередь создание следующей партиции при достижении —
// без него архивация никогда не сработает. Сбой запроса не должен
// блокировать подачу заявки — откатываемся на список по конвенции имён.
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
const { id, code } = await saveSelectionRequest(
requestId,
rtId,
data,
toApprovers(matrix.rows),
status,
writeListId
);
setRequestId(id); setRequestId(id);
setRequestStatus(status); setRequestStatus(status);
if (code) { if (code) {
setRequestCode(code); setRequestCode(code);
} }
if (files.length > 0) { if (files.length > 0) {
await uploadAttachments(dataListTitle(FORM_ID), id, files); // файлы — после создания элемента await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files); // файлы — после создания элемента
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
setExistingAttachments(await getAttachments(dataListTitle(FORM_ID), id)); setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id));
} }
if (onSaved) { if (onSaved) {
onSaved(); onSaved();
} }
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения). // Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
// Имена списков — той же партиции, куда фактически записали (writeListId),
// не обязательно текущей на момент рендера формы: поток ищет строку
// маршрута именно в НЕЙ.
if (status === REQUEST_STATUS.onApproval) { if (status === REQUEST_STATUS.onApproval) {
await notifyActiveStep(context, approval, id, { await notifyActiveStep(context, approval, id, {
requestTypeId: rtId, requestTypeId: rtId,
dataListName: dataListTitle(FORM_ID), dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID) approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
}); });
} }
}; };
@@ -177,10 +211,11 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ requestType, con
if (matrix.error) { if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`; return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
} }
const unassigned = findUnassignedStep(matrix.rows); const unassignedError = unassignedStepMessage(
if (unassigned) { matrix.rows,
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`; (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
} );
if (unassignedError) return unassignedError;
return undefined; return undefined;
}; };
@@ -199,7 +234,12 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ requestType, con
<FormCard <FormCard
title={requestType.title} title={requestType.title}
subtitle="Категория: Отдел рекрутинга" subtitle="Категория: Отдел рекрутинга"
belowSubtitle={<InitiatorLine listTitle={dataListTitle(FORM_ID)} itemId={requestId} />} belowSubtitle={
<InitiatorLine
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
itemId={requestId}
/>
}
headerRight={ headerRight={
requestId || requestStatus ? ( requestId || requestStatus ? (
<> <>
@@ -13,7 +13,12 @@ import {
useRequestTypeId useRequestTypeId
} from '../../shared/approval'; } from '../../shared/approval';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadSelectionRequest } from './load'; import { loadSelectionRequest } from './load';
import { selectionApprovalService } from './submit'; import { selectionApprovalService } from './submit';
@@ -28,6 +33,8 @@ interface IRecruitingSelectionViewProps {
itemId: number; itemId: number;
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */ /** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
formKey: string; formKey: string;
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
listId?: string;
} }
// В режиме чтения поля не редактируются, поэтому обновление — пустышка. // В режиме чтения поля не редактируются, поэтому обновление — пустышка.
@@ -35,7 +42,7 @@ const NOOP_SET: SetField = () => undefined;
// Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же // Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же
// секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>. // секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>.
const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ context, itemId, formKey }) => { const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ context, itemId, formKey, listId }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
@@ -43,9 +50,14 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const approval = useMemo(() => selectionApprovalService(), []); // listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
// selectionApprovalService можно звать не опасаясь исключения.
const approval = useMemo(() => selectionApprovalService(listId), [listId]);
const { id: requestTypeId } = useRequestTypeId(context, formKey); const { id: requestTypeId } = useRequestTypeId(context, formKey);
const approverService = useMemo(() => new ApproverService(approval, dataListTitle(FORM_ID)), [approval]); // Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
const dataListRef = listId || dataListTitle(FORM_ID);
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в // Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу. // reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
@@ -58,7 +70,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
setLoading(true); setLoading(true);
Promise.all([loadSelectionRequest(itemId), getAttachments(dataListTitle(FORM_ID), itemId)]) Promise.all([loadSelectionRequest(itemId, listId), getAttachments(listId || dataListTitle(FORM_ID), itemId)])
.then(([loaded, files]) => { .then(([loaded, files]) => {
if (alive) { if (alive) {
setData(loaded.data); setData(loaded.data);
@@ -78,7 +90,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
return () => { return () => {
alive = false; alive = false;
}; };
}, [itemId]); }, [itemId, listId]);
if (loading) { if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>; return <div className={styles.state}>Загрузка заявки…</div>;
@@ -97,8 +109,8 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
if (!requestTypeId) return; if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, { notifyActiveStep(context, approval, itemId, {
requestTypeId, requestTypeId,
dataListName: dataListTitle(FORM_ID), dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID) approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined); }).catch(() => undefined);
}; };
@@ -112,7 +124,12 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
<FormCard <FormCard
title="Заявка на подбор" title="Заявка на подбор"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={<InitiatorLine listTitle={dataListTitle(FORM_ID)} itemId={itemId} />} belowSubtitle={
<InitiatorLine
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
itemId={itemId}
/>
}
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={status ? <StatusBadge status={status} /> : undefined}
> >
<FormModeProvider readOnly> <FormModeProvider readOnly>
@@ -34,8 +34,15 @@ export interface ILoadedRequest {
code: string; code: string;
} }
/** Прочитать заявку «на подбор» по id: модель формы + статус (правка / просмотр). */ /**
export async function loadSelectionRequest(itemId: number): Promise<ILoadedRequest> { * Прочитать заявку «на подбор» по id: модель формы + статус (правка / просмотр).
*
* listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/
* listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции (как раньше); задан —
* идём напрямую по url списка, а не по имени: так не нужно резолвить, как
* называется архивный список, и запрос не зависит от реестра партиций.
*/
export async function loadSelectionRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
const keys = Object.keys(EMPTY_DATA) as (keyof IFormData)[]; const keys = Object.keys(EMPTY_DATA) as (keyof IFormData)[];
const isPerson = (k: keyof IFormData): boolean => const isPerson = (k: keyof IFormData): boolean =>
PERSON_FIELDS.indexOf(k) !== -1 || MULTI_PERSON_FIELDS.indexOf(k) !== -1; PERSON_FIELDS.indexOf(k) !== -1 || MULTI_PERSON_FIELDS.indexOf(k) !== -1;
@@ -47,8 +54,8 @@ export async function loadSelectionRequest(itemId: number): Promise<ILoadedReque
const personSelects = [...PERSON_FIELDS, ...MULTI_PERSON_FIELDS].map((k) => `${k}/EMail`); const personSelects = [...PERSON_FIELDS, ...MULTI_PERSON_FIELDS].map((k) => `${k}/EMail`);
const expands = [...PERSON_FIELDS, ...MULTI_PERSON_FIELDS]; const expands = [...PERSON_FIELDS, ...MULTI_PERSON_FIELDS];
const item = (await getSP() const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
.web.lists.getByTitle(dataListTitle(FORM_ID)) const item = (await list
.items.getById(itemId) .items.getById(itemId)
.select( .select(
...scalarSelects, ...scalarSelects,
@@ -1,7 +1,6 @@
import { getSP } from '../../shared/pnp/pnpjs'; import { getSP } from '../../shared/pnp/pnpjs';
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval'; import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { buildRequestUniqueCode } from '../../shared/requestCode'; import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData } from './model'; import { FORM_ID, IFormData } from './model';
// Поля-люди (один человек): логин → ensureUser → Id в колонку <Field>Id. // Поля-люди (один человек): логин → ensureUser → Id в колонку <Field>Id.
@@ -11,9 +10,13 @@ const MULTI_PERSON_FIELDS: (keyof IFormData)[] = ['HiringDecisionMaker'];
// Числовые поля: пустая строка → null, иначе Number(). // Числовые поля: пустая строка → null, иначе Number().
const NUMBER_FIELDS: (keyof IFormData)[] = ['OpenVacanciesCount', 'RateProbation', 'RateAfterProbation']; const NUMBER_FIELDS: (keyof IFormData)[] = ['OpenVacanciesCount', 'RateProbation', 'RateAfterProbation'];
/** Экземпляр сервиса списка согласования этой формы. */ /**
export function selectionApprovalService(): ApprovalListService { * Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
return new ApprovalListService(approvalListTitle(FORM_ID)); * approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
* именем формы для читаемости вызывающего кода.
*/
export function selectionApprovalService(listId?: string): ApprovalListService {
return approvalServiceFor(FORM_ID, listId);
} }
/** Логин → числовой Id пользователя на текущем сайте. */ /** Логин → числовой Id пользователя на текущем сайте. */
@@ -77,43 +80,35 @@ async function applyPeople(item: Record<string, unknown>, data: IFormData): Prom
} }
} }
export interface ISaveResult { export type ISaveResult = ISaveItemResult;
id: number;
/** Задан только при создании новой заявки (существующий код не меняется). */
code?: string;
}
/** /**
* Сохранить заявку «на подбор»: содержимое + маршрут согласования. * Сохранить заявку «на подбор»: содержимое + маршрут согласования.
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* status = «Черновик» (сохранение) или «На согласовании» (отправка). Фазы исполнения * status = «Черновик» (сохранение) или «На согласовании» (отправка). Фазы исполнения
* нет — writeRoute вызывается с пустым списком исполнителей. Возвращает Id (+ код при создании). * нет — writeRoute вызывается с пустым списком исполнителей. Возвращает Id (+ код при создании).
*
* listId — партиция, в которую фактически пишем: при редактировании — партиция
* ОБНОВЛЯЕМОЙ заявки (см. loadSelectionRequest), при создании — партиция,
* полученная от approval-route API непосредственно перед вызовом (см.
* getCurrentListPartition в RecruitingSelectionForm.tsx) — именно этот вызов,
* а не кеш из onInit, проверяет порог элементов и запускает архивацию
* следующей партиции. Не задан (сбой запроса за текущей партицией) → откат
* на список по конвенции имён, как до появления партиционирования.
*/ */
export async function saveSelectionRequest( export async function saveSelectionRequest(
existingId: number | undefined, existingId: number | undefined,
requestTypeId: number, requestTypeId: number,
data: IFormData, data: IFormData,
approvers: IApproverToWrite[], approvers: IApproverToWrite[],
status: string status: string,
listId?: string
): Promise<ISaveResult> { ): Promise<ISaveResult> {
const list = getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = buildScalarBody(data); const item = buildScalarBody(data);
item.RequestStatus = status; item.RequestStatus = status;
await applyPeople(item, data); await applyPeople(item, data);
let id: number; const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
let code: string | undefined; await writeRoute(selectionApprovalService(listId), result.id, approvers, [], status);
if (existingId) { return result;
await list.items.getById(existingId).update(item);
id = existingId;
} else {
const added = await list.items.add(item);
id = (added as { Id: number }).Id;
// Код известен только после создания элемента (нужен его Id).
code = buildRequestUniqueCode(requestTypeId, id, new Date());
await list.items.getById(id).update({ RequestUniqueCode: code });
}
await writeRoute(selectionApprovalService(), id, approvers, [], status);
return { id, code };
} }
@@ -20,12 +20,13 @@ import {
StatusBadge, StatusBadge,
useMatrixEditor, useMatrixEditor,
getPlannedMatrix, getPlannedMatrix,
resolveWriteListId,
notifyActiveStep, notifyActiveStep,
useRequestTypeId, useRequestTypeId,
REQUEST_STATUS, REQUEST_STATUS,
isEditableStatus, isEditableStatus,
IApproverToWrite, IApproverToWrite,
findUnassignedStep unassignedStepMessage
} from '../../shared/approval'; } from '../../shared/approval';
import { import {
uploadAttachments, uploadAttachments,
@@ -35,7 +36,12 @@ import {
} from '../../shared/attachments/attachments.service'; } from '../../shared/attachments/attachments.service';
import { effectiveUserEmail } from '../../shared/impersonation'; import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types'; import { IRequestFormProps } from '../../models/types';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { saveTicketRequest, setPlannerTaskId, ticket1cApprovalService } from './submit'; import { saveTicketRequest, setPlannerTaskId, ticket1cApprovalService } from './submit';
import { loadTicketRequest } from './load'; import { loadTicketRequest } from './load';
@@ -63,7 +69,14 @@ function buildPlannerNotes(data: IFormData, code: string): string {
return lines.join('\n'); return lines.join('\n');
} }
const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, editId }) => { const Ticket1CForm: React.FC<IRequestFormProps> = ({
requestType,
context,
onCancel,
onSaved,
editId,
editListId
}) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]); const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]);
@@ -81,7 +94,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
return; return;
} }
let alive = true; let alive = true;
Promise.all([loadTicketRequest(editId), getAttachments(dataListTitle(FORM_ID), editId)]) Promise.all([loadTicketRequest(editId, editListId), getAttachments(editListId || dataListTitle(FORM_ID), editId)])
.then(([loaded, atts]) => { .then(([loaded, atts]) => {
if (alive) { if (alive) {
setData(loaded.data); setData(loaded.data);
@@ -99,7 +112,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
alive = false; alive = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [editId]); }, [editId, editListId]);
// Предзаполнение контактов заявителя текущим пользователем — только для новой заявки. // Предзаполнение контактов заявителя текущим пользователем — только для новой заявки.
// Телефон/отдел/должность в pageContext нет — берём из Graph (/me), с фолбэком. // Телефон/отдел/должность в pageContext нет — берём из Graph (/me), с фолбэком.
@@ -127,7 +140,10 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [editId]); }, [editId]);
const approval = useMemo(() => ticket1cApprovalService(), []); // editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
// ticket1cApprovalService можно звать не опасаясь исключения.
const approval = useMemo(() => ticket1cApprovalService(editListId), [editListId]);
const userEmail = effectiveUserEmail(context); const userEmail = effectiveUserEmail(context);
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API. // data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
@@ -188,7 +204,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
if (requestId === undefined) { if (requestId === undefined) {
return; return;
} }
deleteAttachment(dataListTitle(FORM_ID), requestId, fileName) deleteAttachment(editListId || dataListTitle(FORM_ID), requestId, fileName)
.then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName))) .then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName)))
.catch(() => undefined); .catch(() => undefined);
}; };
@@ -210,7 +226,22 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
? REQUEST_STATUS.onApproval ? REQUEST_STATUS.onApproval
: REQUEST_STATUS.approved; : REQUEST_STATUS.approved;
const { id, code } = await saveTicketRequest(requestId, data, toApprovers(matrix.rows), finalStatus); // requestId уже задан → редактируем существующую заявку, пишем в ту же
// партицию (editListId). Иначе — создаём новую, и партицию для записи
// спрашиваем у approval-route ПРЯМО СЕЙЧАС (а не берём из кеша onInit):
// это единственный запрос, который проверяет порог элементов текущего
// списка и ставит в очередь создание следующей партиции при достижении —
// без него архивация никогда не сработает. Сбой запроса не должен
// блокировать подачу заявки — откатываемся на список по конвенции имён.
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
const { id, code } = await saveTicketRequest(
requestId,
data,
toApprovers(matrix.rows),
finalStatus,
writeListId
);
setRequestId(id); setRequestId(id);
setRequestStatus(finalStatus); setRequestStatus(finalStatus);
if (code) { if (code) {
@@ -219,9 +250,9 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
let attachments = existingAttachments; let attachments = existingAttachments;
if (files.length > 0) { if (files.length > 0) {
await uploadAttachments(dataListTitle(FORM_ID), id, files); await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files);
setFiles([]); setFiles([]);
attachments = await getAttachments(dataListTitle(FORM_ID), id); attachments = await getAttachments(writeListId || dataListTitle(FORM_ID), id);
setExistingAttachments(attachments); setExistingAttachments(attachments);
} }
@@ -230,10 +261,13 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
} }
if (finalStatus === REQUEST_STATUS.onApproval) { if (finalStatus === REQUEST_STATUS.onApproval) {
// Имена списков — той же партиции, куда фактически записали (writeListId),
// не обязательно текущей на момент рендера формы: поток ищет строку
// маршрута именно в НЕЙ.
await notifyActiveStep(context, approval, id, { await notifyActiveStep(context, approval, id, {
requestTypeId: rtId, requestTypeId: rtId,
dataListName: dataListTitle(FORM_ID), dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID) approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
}); });
} else if (finalStatus === REQUEST_STATUS.approved) { } else if (finalStatus === REQUEST_STATUS.approved) {
// Согласование не требуется для этого вида работ — сразу передаём в отдел 1С. // Согласование не требуется для этого вида работ — сразу передаём в отдел 1С.
@@ -245,7 +279,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
attachments, attachments,
webAbsoluteUrl: context.pageContext.web.absoluteUrl webAbsoluteUrl: context.pageContext.web.absoluteUrl
}); });
await setPlannerTaskId(id, taskId); await setPlannerTaskId(id, taskId, writeListId);
} }
}; };
@@ -262,10 +296,11 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
// Матрица нередактируема (маршрут задаётся видом работ, а не заявителем) — // Матрица нередактируема (маршрут задаётся видом работ, а не заявителем) —
// незаполненный шаг заявитель исправить сам не может, только администратор // незаполненный шаг заявитель исправить сам не может, только администратор
// маршрутов (роль в шаге не разрешилась в конкретного человека). // маршрутов (роль в шаге не разрешилась в конкретного человека).
const unassigned = findUnassignedStep(matrix.rows); const unassignedError = unassignedStepMessage(
if (unassigned) { matrix.rows,
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») не назначен на конкретного человека. Обратитесь к администратору маршрутов согласования.`; (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») не назначен на конкретного человека. Обратитесь к администратору маршрутов согласования.`
} );
if (unassignedError) return unassignedError;
return undefined; return undefined;
}; };
@@ -284,7 +319,12 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
<FormCard <FormCard
title={requestType.title} title={requestType.title}
subtitle="Категория: IT" subtitle="Категория: IT"
belowSubtitle={<InitiatorLine listTitle={dataListTitle(FORM_ID)} itemId={requestId} />} belowSubtitle={
<InitiatorLine
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
itemId={requestId}
/>
}
headerRight={ headerRight={
requestId || requestStatus ? ( requestId || requestStatus ? (
<> <>
@@ -27,7 +27,12 @@ import {
REQUEST_STATUS REQUEST_STATUS
} from '../../shared/approval'; } from '../../shared/approval';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadTicketRequest } from './load'; import { loadTicketRequest } from './load';
import { ticket1cApprovalService, getPlannerTaskId, setPlannerTaskId } from './submit'; import { ticket1cApprovalService, getPlannerTaskId, setPlannerTaskId } from './submit';
@@ -41,6 +46,8 @@ interface ITicket1CViewProps {
itemId: number; itemId: number;
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */ /** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
formKey: string; formKey: string;
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
listId?: string;
} }
/** Текст карточки Planner из данных заявки; rejection — автор и причина отклонения, если есть. */ /** Текст карточки Planner из данных заявки; rejection — автор и причина отклонения, если есть. */
@@ -72,7 +79,7 @@ function buildPlannerNotes(
} }
// Заявка в отдел 1С только для просмотра (режим согласующего). // Заявка в отдел 1С только для просмотра (режим согласующего).
const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }) => { const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey, listId }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
@@ -83,11 +90,13 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }
const { id: requestTypeId } = useRequestTypeId(context, formKey); const { id: requestTypeId } = useRequestTypeId(context, formKey);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
const approverService = useMemo( // listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
() => new ApproverService(ticket1cApprovalService(), dataListTitle(FORM_ID)), // ticket1cApprovalService можно звать не опасаясь исключения.
[] const approval = useMemo(() => ticket1cApprovalService(listId), [listId]);
); // Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
const approval = useMemo(() => ticket1cApprovalService(), []); // архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
const dataListRef = listId || dataListTitle(FORM_ID);
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
// decisionSeq входит в reloadKey: после решения строки маршрута меняются на // decisionSeq входит в reloadKey: после решения строки маршрута меняются на
// сервере, поэтому счётчик инкрементируется и useMatrixEditor перечитывает матрицу. // сервере, поэтому счётчик инкрементируется и useMatrixEditor перечитывает матрицу.
@@ -100,7 +109,7 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
setLoading(true); setLoading(true);
Promise.all([loadTicketRequest(itemId), getAttachments(dataListTitle(FORM_ID), itemId)]) Promise.all([loadTicketRequest(itemId, listId), getAttachments(listId || dataListTitle(FORM_ID), itemId)])
.then(([loaded, files]) => { .then(([loaded, files]) => {
if (alive) { if (alive) {
setData(loaded.data); setData(loaded.data);
@@ -120,14 +129,14 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }
return () => { return () => {
alive = false; alive = false;
}; };
}, [itemId]); }, [itemId, listId]);
// Передать согласованную/отклонённую заявку в Planner. PlannerTaskId — защита // Передать согласованную/отклонённую заявку в Planner. PlannerTaskId — защита
// от повторного создания карточки, если решение применяется из нескольких // от повторного создания карточки, если решение применяется из нескольких
// вкладок/сессий одновременно (см. submit.ts). // вкладок/сессий одновременно (см. submit.ts).
const handoffToPlanner = useCallback( const handoffToPlanner = useCallback(
async (finalStatus: string, currentData: IFormData, currentCode: string): Promise<void> => { async (finalStatus: string, currentData: IFormData, currentCode: string): Promise<void> => {
const existingTaskId = await getPlannerTaskId(itemId); const existingTaskId = await getPlannerTaskId(itemId, listId);
if (existingTaskId) { if (existingTaskId) {
return; return;
} }
@@ -143,7 +152,7 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }
const bucketName = const bucketName =
finalStatus === REQUEST_STATUS.rejected ? PLANNER_REJECTED_BUCKET_NAME : PLANNER_BUCKET_NAME; finalStatus === REQUEST_STATUS.rejected ? PLANNER_REJECTED_BUCKET_NAME : PLANNER_BUCKET_NAME;
const atts = await getAttachments(dataListTitle(FORM_ID), itemId); const atts = await getAttachments(listId || dataListTitle(FORM_ID), itemId);
const taskId = await new PlannerTicketService(context).createTask({ const taskId = await new PlannerTicketService(context).createTask({
planId: PLANNER_PLAN_ID, planId: PLANNER_PLAN_ID,
bucketName, bucketName,
@@ -152,9 +161,9 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }
attachments: atts, attachments: atts,
webAbsoluteUrl: context.pageContext.web.absoluteUrl webAbsoluteUrl: context.pageContext.web.absoluteUrl
}); });
await setPlannerTaskId(itemId, taskId); await setPlannerTaskId(itemId, taskId, listId);
}, },
[approval, context, itemId] [approval, context, itemId, listId]
); );
if (loading) { if (loading) {
@@ -174,8 +183,8 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }
if (!requestTypeId) return; if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, { notifyActiveStep(context, approval, itemId, {
requestTypeId, requestTypeId,
dataListName: dataListTitle(FORM_ID), dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID) approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined); }).catch(() => undefined);
}; };
@@ -185,7 +194,7 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }
{isEditableStatus(status) && ( {isEditableStatus(status) && (
<Button <Button
variant="secondary" variant="secondary"
onClick={() => navigate({ mode: 'edit', nav: 'new', formKey, itemId })} onClick={() => navigate({ mode: 'edit', nav: 'new', formKey, itemId, listId })}
> >
<Icon name="edit" size="sm" /> <Icon name="edit" size="sm" />
Редактировать Редактировать
@@ -198,7 +207,12 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }
<FormCard <FormCard
title="Заявка в отдел 1С" title="Заявка в отдел 1С"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={<InitiatorLine listTitle={dataListTitle(FORM_ID)} itemId={itemId} />} belowSubtitle={
<InitiatorLine
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
itemId={itemId}
/>
}
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={status ? <StatusBadge status={status} /> : undefined}
> >
<FormModeProvider readOnly> <FormModeProvider readOnly>
@@ -14,10 +14,17 @@ export interface ILoadedRequest {
code: string; code: string;
} }
/** Прочитать заявку по id: модель формы + статус (для режима чтения). */ /**
export async function loadTicketRequest(itemId: number): Promise<ILoadedRequest> { * Прочитать заявку по id: модель формы + статус (для режима чтения).
const item = (await getSP() *
.web.lists.getByTitle(dataListTitle(FORM_ID)) * listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/
* listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции (как раньше); задан —
* идём напрямую по url списка, а не по имени: так не нужно резолвить, как
* называется архивный список, и запрос не зависит от реестра партиций.
*/
export async function loadTicketRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = (await list
.items.getById(itemId) .items.getById(itemId)
.select(...SCALAR_FIELDS, 'Title', 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>; .select(...SCALAR_FIELDS, 'Title', 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>;
@@ -1,7 +1,5 @@
import { getSP } from '../../shared/pnp/pnpjs'; import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval'; import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList';
import { buildRequestUniqueCode } from '../../shared/requestCode';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData } from './model'; import { FORM_ID, IFormData } from './model';
// Префикс уникального кода заявки — буквенная метка «1С» вместо числового id // Префикс уникального кода заявки — буквенная метка «1С» вместо числового id
@@ -9,9 +7,13 @@ import { FORM_ID, IFormData } from './model';
// больше не хардкодится — см. useRequestTypeId в Form.tsx/View.tsx. // больше не хардкодится — см. useRequestTypeId в Form.tsx/View.tsx.
export const TICKET1C_CODE_PREFIX = '1С'; export const TICKET1C_CODE_PREFIX = '1С';
/** Экземпляр сервиса списка согласования этой формы. */ /**
export function ticket1cApprovalService(): ApprovalListService { * Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
return new ApprovalListService(approvalListTitle(FORM_ID)); * approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
* именем формы для читаемости вызывающего кода.
*/
export function ticket1cApprovalService(listId?: string): ApprovalListService {
return approvalServiceFor(FORM_ID, listId);
} }
/** Поля элемента списка данных из модели формы. TaskName пишется в системную колонку Title. */ /** Поля элемента списка данных из модели формы. TaskName пишется в системную колонку Title. */
@@ -27,42 +29,34 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
return item; return item;
} }
export interface ISaveResult { export type ISaveResult = ISaveItemResult;
id: number;
/** Задан только при создании новой заявки (существующий код не меняется). */
code?: string;
}
/** /**
* Сохранить заявку в отдел 1С: содержимое + маршрут согласования. * Сохранить заявку в отдел 1С: содержимое + маршрут согласования.
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Фазы исполнения нет: после согласования заявка уходит в Planner, а не исполнителю * Фазы исполнения нет: после согласования заявка уходит в Planner, а не исполнителю
* из матрицы. Возвращает Id заявки (+ код при создании). * из матрицы. Возвращает Id заявки (+ код при создании).
*
* listId — партиция, в которую фактически пишем: при редактировании — партиция
* ОБНОВЛЯЕМОЙ заявки (см. loadTicketRequest), при создании — партиция,
* полученная от approval-route API непосредственно перед вызовом (см.
* getCurrentListPartition в Ticket1CForm.tsx) — именно этот вызов, а не кеш из
* onInit, проверяет порог элементов и запускает архивацию следующей партиции.
* Не задан (сбой запроса за текущей партицией) → откат на список по конвенции
* имён, как до появления партиционирования.
*/ */
export async function saveTicketRequest( export async function saveTicketRequest(
existingId: number | undefined, existingId: number | undefined,
data: IFormData, data: IFormData,
approvers: IApproverToWrite[], approvers: IApproverToWrite[],
status: string status: string,
listId?: string
): Promise<ISaveResult> { ): Promise<ISaveResult> {
const list = getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = buildItem(data, status); const item = buildItem(data, status);
let id: number; const result = await saveRequestItem(FORM_ID, existingId, listId, TICKET1C_CODE_PREFIX, item);
let code: string | undefined; await writeRoute(ticket1cApprovalService(listId), result.id, approvers, [], status);
if (existingId) { return result;
await list.items.getById(existingId).update(item);
id = existingId;
} else {
const added = await list.items.add(item);
id = (added as { Id: number }).Id;
// Код известен только после создания элемента (нужен его Id).
code = buildRequestUniqueCode(TICKET1C_CODE_PREFIX, id, new Date());
await list.items.getById(id).update({ RequestUniqueCode: code });
}
await writeRoute(ticket1cApprovalService(), id, approvers, [], status);
return { id, code };
} }
/** /**
@@ -70,14 +64,13 @@ export async function saveTicketRequest(
* после createTask — служит защитой от повторного создания карточки, если * после createTask — служит защитой от повторного создания карточки, если
* решение согласующего применяется из нескольких вкладок/сессий одновременно. * решение согласующего применяется из нескольких вкладок/сессий одновременно.
*/ */
export async function getPlannerTaskId(id: number): Promise<string | undefined> { export async function getPlannerTaskId(id: number, listId?: string): Promise<string | undefined> {
const item = (await getSP() const list = resolveDataList(FORM_ID, listId);
.web.lists.getByTitle(dataListTitle(FORM_ID)) const item = (await list.items.getById(id).select('PlannerTaskId')()) as { PlannerTaskId?: string };
.items.getById(id)
.select('PlannerTaskId')()) as { PlannerTaskId?: string };
return item.PlannerTaskId || undefined; return item.PlannerTaskId || undefined;
} }
export async function setPlannerTaskId(id: number, taskId: string): Promise<void> { export async function setPlannerTaskId(id: number, taskId: string, listId?: string): Promise<void> {
await getSP().web.lists.getByTitle(dataListTitle(FORM_ID)).items.getById(id).update({ PlannerTaskId: taskId }); const list = resolveDataList(FORM_ID, listId);
await list.items.getById(id).update({ PlannerTaskId: taskId });
} }
@@ -26,7 +26,8 @@ import {
isEditableStatus, isEditableStatus,
IApproverToWrite, IApproverToWrite,
APPROVAL_PHASE, APPROVAL_PHASE,
findUnassignedStep unassignedStepMessage,
resolveWriteListId
} from '../../shared/approval'; } from '../../shared/approval';
import { import {
uploadAttachments, uploadAttachments,
@@ -36,14 +37,26 @@ import {
} from '../../shared/attachments/attachments.service'; } from '../../shared/attachments/attachments.service';
import { effectiveUserEmail } from '../../shared/impersonation'; import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types'; import { IRequestFormProps } from '../../models/types';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { saveTmcTransferRequest, tmcTransferApprovalService } from './submit'; import { saveTmcTransferRequest, tmcTransferApprovalService } from './submit';
import { loadTmcTransferRequest } from './load'; import { loadTmcTransferRequest } from './load';
import { HeaderSection } from './sections/HeaderSection'; import { HeaderSection } from './sections/HeaderSection';
import { ItemsSection } from './sections/ItemsSection'; import { ItemsSection } from './sections/ItemsSection';
const TmcTransferForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, editId }) => { const TmcTransferForm: React.FC<IRequestFormProps> = ({
requestType,
context,
onCancel,
onSaved,
editId,
editListId
}) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]); const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]);
@@ -80,7 +93,7 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({ requestType, context, on
return; return;
} }
let alive = true; let alive = true;
Promise.all([loadTmcTransferRequest(editId), getAttachments(dataListTitle(FORM_ID), editId)]) Promise.all([loadTmcTransferRequest(editId, editListId), getAttachments(editListId || dataListTitle(FORM_ID), editId)])
.then(([loaded, atts]) => { .then(([loaded, atts]) => {
if (alive) { if (alive) {
setData(loaded.data); setData(loaded.data);
@@ -97,9 +110,12 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({ requestType, context, on
return () => { return () => {
alive = false; alive = false;
}; };
}, [editId]); }, [editId, editListId]);
const approval = useMemo(() => tmcTransferApprovalService(), []); // editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
// tmcTransferApprovalService можно звать не опасаясь исключения.
const approval = useMemo(() => tmcTransferApprovalService(editListId), [editListId]);
const userEmail = effectiveUserEmail(context); const userEmail = effectiveUserEmail(context);
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API. // data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
@@ -174,7 +190,7 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({ requestType, context, on
if (requestId === undefined) { if (requestId === undefined) {
return; return;
} }
deleteAttachment(dataListTitle(FORM_ID), requestId, fileName) deleteAttachment(editListId || dataListTitle(FORM_ID), requestId, fileName)
.then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName))) .then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName)))
.catch(() => undefined); .catch(() => undefined);
}; };
@@ -187,13 +203,18 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({ requestType, context, on
if (!rtId) { if (!rtId) {
throw new Error('Тип заявки не определён — обновите страницу и повторите.'); throw new Error('Тип заявки не определён — обновите страницу и повторите.');
} }
// Новая заявка — резолвим ТЕКУЩУЮ партицию через approval-route (заодно
// выполняется проверка порога и создание следующей архивной партиции при
// необходимости); при редактировании остаёмся в партиции самой заявки.
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
const { id, code } = await saveTmcTransferRequest( const { id, code } = await saveTmcTransferRequest(
requestId, requestId,
rtId, rtId,
data, data,
toApprovers(matrix.rows), toApprovers(matrix.rows),
toApprovers(executors.rows), toApprovers(executors.rows),
status status,
writeListId
); );
setRequestId(id); setRequestId(id);
setRequestStatus(status); setRequestStatus(status);
@@ -201,18 +222,22 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({ requestType, context, on
setRequestCode(code); setRequestCode(code);
} }
if (files.length > 0) { if (files.length > 0) {
await uploadAttachments(dataListTitle(FORM_ID), id, files); await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files);
setFiles([]); setFiles([]);
setExistingAttachments(await getAttachments(dataListTitle(FORM_ID), id)); setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id));
} }
if (onSaved) { if (onSaved) {
onSaved(); onSaved();
} }
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
// Имена списков — той же партиции, куда только что записали (writeListId),
// а не editListId: для новой заявки это может быть только что созданная
// текущая партиция, отличная от editListId (который тут undefined).
if (status === REQUEST_STATUS.onApproval) { if (status === REQUEST_STATUS.onApproval) {
await notifyActiveStep(context, approval, id, { await notifyActiveStep(context, approval, id, {
requestTypeId: rtId, requestTypeId: rtId,
dataListName: dataListTitle(FORM_ID), dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID) approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
}); });
} }
}; };
@@ -220,14 +245,16 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({ requestType, context, on
const validateSubmit = (): string | undefined => { const validateSubmit = (): string | undefined => {
const formError = validate(); const formError = validate();
if (formError) return formError; if (formError) return formError;
const unassigned = findUnassignedStep(matrix.rows); const unassignedApproverError = unassignedStepMessage(
if (unassigned) { matrix.rows,
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`; (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
} );
const unassignedExecutor = findUnassignedStep(executors.rows); if (unassignedApproverError) return unassignedApproverError;
if (unassignedExecutor) { const unassignedExecutorError = unassignedStepMessage(
return `Шаг ${unassignedExecutor.order} исполнения («${unassignedExecutor.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`; executors.rows,
} (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`
);
if (unassignedExecutorError) return unassignedExecutorError;
return undefined; return undefined;
}; };
@@ -248,7 +275,12 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({ requestType, context, on
<FormCard <FormCard
title={requestType.title} title={requestType.title}
subtitle="Категория: Склад / Снабжение" subtitle="Категория: Склад / Снабжение"
belowSubtitle={<InitiatorLine listTitle={dataListTitle(FORM_ID)} itemId={requestId} />} belowSubtitle={
<InitiatorLine
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
itemId={requestId}
/>
}
headerRight={ headerRight={
requestId || requestStatus ? ( requestId || requestStatus ? (
<> <>
@@ -27,7 +27,12 @@ import {
APPROVAL_PHASE APPROVAL_PHASE
} from '../../shared/approval'; } from '../../shared/approval';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadTmcTransferRequest } from './load'; import { loadTmcTransferRequest } from './load';
import { tmcTransferApprovalService } from './submit'; import { tmcTransferApprovalService } from './submit';
@@ -39,10 +44,12 @@ interface ITmcTransferViewProps {
itemId: number; itemId: number;
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */ /** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
formKey: string; formKey: string;
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
listId?: string;
} }
// Заявка «СЗ на перемещение ТМЦ» только для просмотра (режим согласующего). // Заявка «СЗ на перемещение ТМЦ» только для просмотра (режим согласующего).
const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, formKey }) => { const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, formKey, listId }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
@@ -54,13 +61,15 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
// В просмотре форма read-only, но секции ждут set — даём no-op. // В просмотре форма read-only, но секции ждут set — даём no-op.
const set: SetField = () => undefined; const set: SetField = () => undefined;
const approverService = useMemo( // listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
() => new ApproverService(tmcTransferApprovalService(), dataListTitle(FORM_ID)), // tmcTransferApprovalService можно звать не опасаясь исключения.
[] const approval = useMemo(() => tmcTransferApprovalService(listId), [listId]);
); // Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
const dataListRef = listId || dataListTitle(FORM_ID);
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
const [decisionSeq, setDecisionSeq] = useState<number>(0); const [decisionSeq, setDecisionSeq] = useState<number>(0);
const approval = useMemo(() => tmcTransferApprovalService(), []);
const loadApprovers = useCallback( const loadApprovers = useCallback(
() => () =>
approval approval
@@ -85,7 +94,7 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
setLoading(true); setLoading(true);
Promise.all([loadTmcTransferRequest(itemId), getAttachments(dataListTitle(FORM_ID), itemId)]) Promise.all([loadTmcTransferRequest(itemId, listId), getAttachments(listId || dataListTitle(FORM_ID), itemId)])
.then(([loaded, files]) => { .then(([loaded, files]) => {
if (alive) { if (alive) {
setData(loaded.data); setData(loaded.data);
@@ -105,7 +114,7 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
return () => { return () => {
alive = false; alive = false;
}; };
}, [itemId]); }, [itemId, listId]);
if (loading) { if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>; return <div className={styles.state}>Загрузка заявки…</div>;
@@ -124,8 +133,8 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
if (!requestTypeId) return; if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, { notifyActiveStep(context, approval, itemId, {
requestTypeId, requestTypeId,
dataListName: dataListTitle(FORM_ID), dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID) approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined); }).catch(() => undefined);
}; };
@@ -135,7 +144,7 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
{isEditableStatus(status) && ( {isEditableStatus(status) && (
<Button <Button
variant="secondary" variant="secondary"
onClick={() => navigate({ mode: 'edit', nav: 'new', formKey, itemId })} onClick={() => navigate({ mode: 'edit', nav: 'new', formKey, itemId, listId })}
> >
<Icon name="edit" size="sm" /> <Icon name="edit" size="sm" />
Редактировать Редактировать
@@ -148,7 +157,12 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
<FormCard <FormCard
title="Служебная записка на перемещение ТМЦ" title="Служебная записка на перемещение ТМЦ"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={<InitiatorLine listTitle={dataListTitle(FORM_ID)} itemId={itemId} />} belowSubtitle={
<InitiatorLine
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
itemId={itemId}
/>
}
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={status ? <StatusBadge status={status} /> : undefined}
> >
<FormModeProvider readOnly> <FormModeProvider readOnly>
@@ -21,10 +21,17 @@ function parseItems(raw: unknown): ILineItem[] {
} }
} }
/** Прочитать заявку «СЗ на перемещение ТМЦ» по id: модель формы + статус + код. */ /**
export async function loadTmcTransferRequest(itemId: number): Promise<ILoadedRequest> { * Прочитать заявку «СЗ на перемещение ТМЦ» по id: модель формы + статус + код.
const item = (await getSP() *
.web.lists.getByTitle(dataListTitle(FORM_ID)) * listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/
* listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции (как раньше); задан —
* идём напрямую по url списка, а не по имени: так не нужно резолвить, как
* называется архивный список, и запрос не зависит от реестра партиций.
*/
export async function loadTmcTransferRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = (await list
.items.getById(itemId) .items.getById(itemId)
.select( .select(
'Id', 'Id',
@@ -1,13 +1,15 @@
import { getSP } from '../../shared/pnp/pnpjs'; import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval'; import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
import { buildRequestUniqueCode } from '../../shared/requestCode';
import { ILineItem } from '../../shared/ui'; import { ILineItem } from '../../shared/ui';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData } from './model'; import { FORM_ID, IFormData } from './model';
/** Экземпляр сервиса списка согласования этой формы. */ /**
export function tmcTransferApprovalService(): ApprovalListService { * Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
return new ApprovalListService(approvalListTitle(FORM_ID)); * approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
* именем формы для читаемости вызывающего кода.
*/
export function tmcTransferApprovalService(listId?: string): ApprovalListService {
return approvalServiceFor(FORM_ID, listId);
} }
/** Осмысленный заголовок элемента (системная колонка Title). */ /** Осмысленный заголовок элемента (системная колонка Title). */
@@ -34,16 +36,18 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
return item; return item;
} }
export interface ISaveResult { export type ISaveResult = ISaveItemResult;
id: number;
/** Задан только при создании новой заявки (существующий код не меняется). */
code?: string;
}
/** /**
* Сохранить заявку «СЗ на перемещение ТМЦ»: контент + маршрут (согласующие + исполнители). * Сохранить заявку «СЗ на перемещение ТМЦ»: контент + маршрут (согласующие + исполнители).
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Возвращает Id заявки (+ уникальный код при создании). * Возвращает Id заявки (+ уникальный код при создании).
*
* listId — партиция, в которую пишем: для обновления существующей заявки —
* партиция ЭТОЙ заявки (см. loadTmcTransferRequest), для новой — ТЕКУЩАЯ
* партиция (см. getCurrentListPartition), которую вызывающая сторона резолвит
* заранее и передаёт сюда, а не текущая-по-умолчанию: именно на этом резолве
* держится проверка порога и создание следующей архивной партиции.
*/ */
export async function saveTmcTransferRequest( export async function saveTmcTransferRequest(
existingId: number | undefined, existingId: number | undefined,
@@ -51,24 +55,12 @@ export async function saveTmcTransferRequest(
data: IFormData, data: IFormData,
approvers: IApproverToWrite[], approvers: IApproverToWrite[],
executors: IApproverToWrite[], executors: IApproverToWrite[],
status: string status: string,
listId?: string
): Promise<ISaveResult> { ): Promise<ISaveResult> {
const list = getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = buildItem(data, status); const item = buildItem(data, status);
let id: number; const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
let code: string | undefined; await writeRoute(tmcTransferApprovalService(listId), result.id, approvers, executors, status);
if (existingId) { return result;
await list.items.getById(existingId).update(item);
id = existingId;
} else {
const added = await list.items.add(item);
id = (added as { Id: number }).Id;
// Код известен только после создания элемента (нужен его Id).
code = buildRequestUniqueCode(requestTypeId, id, new Date());
await list.items.getById(id).update({ RequestUniqueCode: code });
}
await writeRoute(tmcTransferApprovalService(), id, approvers, executors, status);
return { id, code };
} }
@@ -21,16 +21,29 @@ import {
REQUEST_STATUS, REQUEST_STATUS,
isEditableStatus, isEditableStatus,
IApproverToWrite, IApproverToWrite,
findUnassignedStep unassignedStepMessage,
resolveWriteListId
} from '../../shared/approval'; } from '../../shared/approval';
import { IRequestFormProps } from '../../models/types'; import { IRequestFormProps } from '../../models/types';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit'; import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit';
import { loadUnpaidLeaveRequest } from './load'; import { loadUnpaidLeaveRequest } from './load';
import { RequestSection } from './sections/RequestSection'; import { RequestSection } from './sections/RequestSection';
const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, editId }) => { const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
requestType,
context,
onCancel,
onSaved,
editId,
editListId
}) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [requestId, setRequestId] = useState<number | undefined>(editId); const [requestId, setRequestId] = useState<number | undefined>(editId);
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined); const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
@@ -40,7 +53,10 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({ requestType, context, on
const readOnly = !isEditableStatus(requestStatus); const readOnly = !isEditableStatus(requestStatus);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
const approval = useMemo(() => unpaidLeaveApprovalService(), []); // editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
// unpaidLeaveApprovalService можно звать не опасаясь исключения.
const approval = useMemo(() => unpaidLeaveApprovalService(editListId), [editListId]);
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API. // data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
const requestTypeIdState = useRequestTypeId(context, requestType.id); const requestTypeIdState = useRequestTypeId(context, requestType.id);
@@ -52,7 +68,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({ requestType, context, on
return; return;
} }
let alive = true; let alive = true;
loadUnpaidLeaveRequest(editId) loadUnpaidLeaveRequest(editId, editListId)
.then((loaded) => { .then((loaded) => {
if (alive) { if (alive) {
setData(loaded.data); setData(loaded.data);
@@ -69,7 +85,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({ requestType, context, on
alive = false; alive = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [editId]); }, [editId, editListId]);
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя и его должность. // Новая заявка — по умолчанию на себя: подставляем текущего пользователя и его должность.
useEffect(() => { useEffect(() => {
@@ -161,7 +177,18 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({ requestType, context, on
if (!rtId) { if (!rtId) {
throw new Error('Тип заявки не определён — обновите страницу и повторите.'); throw new Error('Тип заявки не определён — обновите страницу и повторите.');
} }
const { id, code } = await saveUnpaidLeaveRequest(requestId, rtId, data, toApprovers(matrix.rows), status); // Новая заявка пишется в ТЕКУЩУЮ партицию — запрашиваем её у approval-route
// API непосредственно перед записью (не кеш из onInit): именно этот вызов
// проверяет порог элементов и запускает архивацию следующей партиции.
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
const { id, code } = await saveUnpaidLeaveRequest(
requestId,
rtId,
data,
toApprovers(matrix.rows),
status,
writeListId
);
setRequestId(id); setRequestId(id);
setRequestStatus(status); setRequestStatus(status);
if (code) { if (code) {
@@ -171,11 +198,13 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({ requestType, context, on
onSaved(); onSaved();
} }
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения). // Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
// Имена списков — той же партиции, куда фактически записана заявка (writeListId),
// не текущей: поток ищет строку маршрута именно в НЕЙ.
if (status === REQUEST_STATUS.onApproval) { if (status === REQUEST_STATUS.onApproval) {
await notifyActiveStep(context, approval, id, { await notifyActiveStep(context, approval, id, {
requestTypeId: rtId, requestTypeId: rtId,
dataListName: dataListTitle(FORM_ID), dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID) approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
}); });
} }
}; };
@@ -188,10 +217,11 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({ requestType, context, on
if (matrix.error) { if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`; return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
} }
const unassigned = findUnassignedStep(matrix.rows); const unassignedError = unassignedStepMessage(
if (unassigned) { matrix.rows,
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`; (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
} );
if (unassignedError) return unassignedError;
return undefined; return undefined;
}; };
@@ -210,7 +240,12 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({ requestType, context, on
<FormCard <FormCard
title={requestType.title} title={requestType.title}
subtitle="Категория: Отдел кадров" subtitle="Категория: Отдел кадров"
belowSubtitle={<InitiatorLine listTitle={dataListTitle(FORM_ID)} itemId={requestId} />} belowSubtitle={
<InitiatorLine
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
itemId={requestId}
/>
}
headerRight={ headerRight={
requestId || requestStatus ? ( requestId || requestStatus ? (
<> <>
@@ -12,7 +12,12 @@ import {
notifyActiveStep, notifyActiveStep,
useRequestTypeId useRequestTypeId
} from '../../shared/approval'; } from '../../shared/approval';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadUnpaidLeaveRequest } from './load'; import { loadUnpaidLeaveRequest } from './load';
import { unpaidLeaveApprovalService } from './submit'; import { unpaidLeaveApprovalService } from './submit';
@@ -23,6 +28,8 @@ interface IUnpaidLeaveViewProps {
itemId: number; itemId: number;
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */ /** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
formKey: string; formKey: string;
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
listId?: string;
} }
// В режиме чтения поля не редактируются, поэтому обновление — пустышка. // В режиме чтения поля не редактируются, поэтому обновление — пустышка.
@@ -31,16 +38,21 @@ const NOOP_SET: SetField = () => undefined;
// Заявка без содержания только для просмотра (режим согласующего). Переиспользует // Заявка без содержания только для просмотра (режим согласующего). Переиспользует
// ту же секцию, что и форма подачи; редактирование выключено через // ту же секцию, что и форма подачи; редактирование выключено через
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми. // <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, formKey }) => { const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, formKey, listId }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const approval = useMemo(() => unpaidLeaveApprovalService(), []); // listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
// unpaidLeaveApprovalService можно звать не опасаясь исключения.
const approval = useMemo(() => unpaidLeaveApprovalService(listId), [listId]);
const { id: requestTypeId } = useRequestTypeId(context, formKey); const { id: requestTypeId } = useRequestTypeId(context, formKey);
const approverService = useMemo(() => new ApproverService(approval, dataListTitle(FORM_ID)), [approval]); // Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
const dataListRef = listId || dataListTitle(FORM_ID);
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в // Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу. // reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
@@ -53,7 +65,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
setLoading(true); setLoading(true);
loadUnpaidLeaveRequest(itemId) loadUnpaidLeaveRequest(itemId, listId)
.then((loaded) => { .then((loaded) => {
if (alive) { if (alive) {
setData(loaded.data); setData(loaded.data);
@@ -72,7 +84,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
return () => { return () => {
alive = false; alive = false;
}; };
}, [itemId]); }, [itemId, listId]);
if (loading) { if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>; return <div className={styles.state}>Загрузка заявки…</div>;
@@ -94,8 +106,8 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
if (!requestTypeId) return; if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, { notifyActiveStep(context, approval, itemId, {
requestTypeId, requestTypeId,
dataListName: dataListTitle(FORM_ID), dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID) approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined); }).catch(() => undefined);
}; };
@@ -109,7 +121,12 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
<FormCard <FormCard
title="Отпуск без содержания" title="Отпуск без содержания"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={<InitiatorLine listTitle={dataListTitle(FORM_ID)} itemId={itemId} />} belowSubtitle={
<InitiatorLine
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
itemId={itemId}
/>
}
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={status ? <StatusBadge status={status} /> : undefined}
> >
<FormModeProvider readOnly> <FormModeProvider readOnly>
@@ -11,10 +11,17 @@ export interface ILoadedRequest {
code: string; code: string;
} }
/** Прочитать заявку без содержания по id: модель формы + статус (правка / просмотр). */ /**
export async function loadUnpaidLeaveRequest(itemId: number): Promise<ILoadedRequest> { * Прочитать заявку без содержания по id: модель формы + статус (правка / просмотр).
const item = (await getSP() *
.web.lists.getByTitle(dataListTitle(FORM_ID)) * listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/
* listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции (как раньше); задан —
* идём напрямую по url списка, а не по имени: так не нужно резолвить, как
* называется архивный список, и запрос не зависит от реестра партиций.
*/
export async function loadUnpaidLeaveRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = (await list
.items.getById(itemId) .items.getById(itemId)
.select( .select(
'Id', 'Id',
@@ -1,18 +1,15 @@
import { getSP } from '../../shared/pnp/pnpjs'; import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval'; import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
import { buildRequestUniqueCode } from '../../shared/requestCode'; import { ensureEmployee } from '../../shared/employee';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData } from './model'; import { FORM_ID, IFormData } from './model';
/** Экземпляр сервиса списка согласования этой формы. */ /**
export function unpaidLeaveApprovalService(): ApprovalListService { * Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
return new ApprovalListService(approvalListTitle(FORM_ID)); * approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
} * именем формы для читаемости вызывающего кода.
*/
/** email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка). */ export function unpaidLeaveApprovalService(listId?: string): ApprovalListService {
async function ensureUser(login: string): Promise<{ Id: number; Title: string }> { return approvalServiceFor(FORM_ID, listId);
const full = login.indexOf('|') >= 0 ? login : `i:0#.f|membership|${login}`;
return getSP().web.ensureUser(full);
} }
function pad2(n: number): string { function pad2(n: number): string {
@@ -55,44 +52,33 @@ function buildItem(
}; };
} }
export interface ISaveResult { export type ISaveResult = ISaveItemResult;
id: number;
/** Задан только при создании новой заявки (существующий код не меняется). */
code?: string;
}
/** /**
* Сохранить заявку без содержания: содержимое + маршрут согласования. * Сохранить заявку без содержания: содержимое + маршрут согласования.
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей. * Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей.
* Возвращает Id заявки (+ код при создании). * Возвращает Id заявки (+ код при создании).
*
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadUnpaidLeaveRequest). Имеет смысл
* только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции —
* писать новые заявки в архив незачем и нельзя (approval-route переключает
* текущую партицию сам, по своему порогу элементов).
*/ */
export async function saveUnpaidLeaveRequest( export async function saveUnpaidLeaveRequest(
existingId: number | undefined, existingId: number | undefined,
requestTypeId: number, requestTypeId: number,
data: IFormData, data: IFormData,
approvers: IApproverToWrite[], approvers: IApproverToWrite[],
status: string status: string,
listId?: string
): Promise<ISaveResult> { ): Promise<ISaveResult> {
const list = getSP().web.lists.getByTitle(dataListTitle(FORM_ID)); const employee = await ensureEmployee(data.Employee);
const employee = await ensureUser(data.Employee);
// Замещающий необязателен: резолвим только если выбран. // Замещающий необязателен: резолвим только если выбран.
const substituteId = data.Substitute ? (await ensureUser(data.Substitute)).Id : undefined; const substituteId = data.Substitute ? (await ensureEmployee(data.Substitute)).Id : undefined;
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee, substituteId); const item = buildItem(data, status, employee.Id, employee.Title || data.Employee, substituteId);
let id: number; const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
let code: string | undefined; await writeRoute(unpaidLeaveApprovalService(listId), result.id, approvers, [], status);
if (existingId) { return result;
await list.items.getById(existingId).update(item);
id = existingId;
} else {
const added = await list.items.add(item);
id = (added as { Id: number }).Id;
// Код известен только после создания элемента (нужен его Id).
code = buildRequestUniqueCode(requestTypeId, id, new Date());
await list.items.getById(id).update({ RequestUniqueCode: code });
}
await writeRoute(unpaidLeaveApprovalService(), id, approvers, [], status);
return { id, code };
} }
@@ -16,21 +16,34 @@ import {
StatusBadge, StatusBadge,
useMatrixEditor, useMatrixEditor,
getPlannedMatrix, getPlannedMatrix,
resolveWriteListId,
notifyActiveStep, notifyActiveStep,
useRequestTypeId, useRequestTypeId,
REQUEST_STATUS, REQUEST_STATUS,
isEditableStatus, isEditableStatus,
IApproverToWrite, IApproverToWrite,
findUnassignedStep unassignedStepMessage
} from '../../shared/approval'; } from '../../shared/approval';
import { IRequestFormProps } from '../../models/types'; import { IRequestFormProps } from '../../models/types';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { saveVacationRequest, vacationApprovalService } from './submit'; import { saveVacationRequest, vacationApprovalService } from './submit';
import { loadVacationRequest } from './load'; import { loadVacationRequest } from './load';
import { RequestSection } from './sections/RequestSection'; import { RequestSection } from './sections/RequestSection';
const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, editId }) => { const VacationForm: React.FC<IRequestFormProps> = ({
requestType,
context,
onCancel,
onSaved,
editId,
editListId
}) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [requestId, setRequestId] = useState<number | undefined>(editId); const [requestId, setRequestId] = useState<number | undefined>(editId);
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined); const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
@@ -40,7 +53,10 @@ const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
const readOnly = !isEditableStatus(requestStatus); const readOnly = !isEditableStatus(requestStatus);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
const approval = useMemo(() => vacationApprovalService(), []); // editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
// vacationApprovalService можно звать не опасаясь исключения.
const approval = useMemo(() => vacationApprovalService(editListId), [editListId]);
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API. // data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
const requestTypeIdState = useRequestTypeId(context, requestType.id); const requestTypeIdState = useRequestTypeId(context, requestType.id);
@@ -52,7 +68,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
return; return;
} }
let alive = true; let alive = true;
loadVacationRequest(editId) loadVacationRequest(editId, editListId)
.then((loaded) => { .then((loaded) => {
if (alive) { if (alive) {
setData(loaded.data); setData(loaded.data);
@@ -69,7 +85,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
alive = false; alive = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [editId]); }, [editId, editListId]);
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя и его должность. // Новая заявка — по умолчанию на себя: подставляем текущего пользователя и его должность.
useEffect(() => { useEffect(() => {
@@ -158,7 +174,18 @@ const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
if (!rtId) { if (!rtId) {
throw new Error('Тип заявки не определён — обновите страницу и повторите.'); throw new Error('Тип заявки не определён — обновите страницу и повторите.');
} }
const { id, code } = await saveVacationRequest(requestId, rtId, data, toApprovers(matrix.rows), status); // requestId уже задан → редактируем существующую заявку, пишем в ту же
// партицию (editListId); иначе резолвится ТЕКУЩАЯ (см. resolveWriteListId —
// именно этот запрос проверяет порог элементов и запускает архивацию).
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
const { id, code } = await saveVacationRequest(
requestId,
rtId,
data,
toApprovers(matrix.rows),
status,
writeListId
);
setRequestId(id); setRequestId(id);
setRequestStatus(status); setRequestStatus(status);
if (code) { if (code) {
@@ -168,11 +195,14 @@ const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
onSaved(); onSaved();
} }
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения). // Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
// Имена списков — той же партиции, куда фактически записали (writeListId),
// не обязательно текущей на момент рендера формы: поток ищет строку
// маршрута именно в НЕЙ.
if (status === REQUEST_STATUS.onApproval) { if (status === REQUEST_STATUS.onApproval) {
await notifyActiveStep(context, approval, id, { await notifyActiveStep(context, approval, id, {
requestTypeId: rtId, requestTypeId: rtId,
dataListName: dataListTitle(FORM_ID), dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID) approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
}); });
} }
}; };
@@ -185,10 +215,11 @@ const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
if (matrix.error) { if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`; return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
} }
const unassigned = findUnassignedStep(matrix.rows); const unassignedError = unassignedStepMessage(
if (unassigned) { matrix.rows,
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`; (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
} );
if (unassignedError) return unassignedError;
return undefined; return undefined;
}; };
@@ -207,7 +238,12 @@ const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
<FormCard <FormCard
title={requestType.title} title={requestType.title}
subtitle="Категория: Отдел кадров" subtitle="Категория: Отдел кадров"
belowSubtitle={<InitiatorLine listTitle={dataListTitle(FORM_ID)} itemId={requestId} />} belowSubtitle={
<InitiatorLine
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
itemId={requestId}
/>
}
headerRight={ headerRight={
requestId || requestStatus ? ( requestId || requestStatus ? (
<> <>
@@ -12,7 +12,12 @@ import {
notifyActiveStep, notifyActiveStep,
useRequestTypeId useRequestTypeId
} from '../../shared/approval'; } from '../../shared/approval';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadVacationRequest } from './load'; import { loadVacationRequest } from './load';
import { vacationApprovalService } from './submit'; import { vacationApprovalService } from './submit';
@@ -23,6 +28,8 @@ interface IVacationViewProps {
itemId: number; itemId: number;
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */ /** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
formKey: string; formKey: string;
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
listId?: string;
} }
// В режиме чтения поля не редактируются, поэтому обновление — пустышка. // В режиме чтения поля не редактируются, поэтому обновление — пустышка.
@@ -31,16 +38,21 @@ const NOOP_SET: SetField = () => undefined;
// Заявка на отпуск только для просмотра (режим согласующего). Переиспользует ту // Заявка на отпуск только для просмотра (режим согласующего). Переиспользует ту
// же секцию, что и форма подачи; редактирование выключено через // же секцию, что и форма подачи; редактирование выключено через
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми. // <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey }) => { const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey, listId }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const approval = useMemo(() => vacationApprovalService(), []); // listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
// vacationApprovalService можно звать не опасаясь исключения.
const approval = useMemo(() => vacationApprovalService(listId), [listId]);
const { id: requestTypeId } = useRequestTypeId(context, formKey); const { id: requestTypeId } = useRequestTypeId(context, formKey);
const approverService = useMemo(() => new ApproverService(approval, dataListTitle(FORM_ID)), [approval]); // Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
const dataListRef = listId || dataListTitle(FORM_ID);
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в // Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу. // reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
@@ -53,7 +65,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey }
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
setLoading(true); setLoading(true);
loadVacationRequest(itemId) loadVacationRequest(itemId, listId)
.then((loaded) => { .then((loaded) => {
if (alive) { if (alive) {
setData(loaded.data); setData(loaded.data);
@@ -72,7 +84,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey }
return () => { return () => {
alive = false; alive = false;
}; };
}, [itemId]); }, [itemId, listId]);
if (loading) { if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>; return <div className={styles.state}>Загрузка заявки…</div>;
@@ -94,8 +106,8 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey }
if (!requestTypeId) return; if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, { notifyActiveStep(context, approval, itemId, {
requestTypeId, requestTypeId,
dataListName: dataListTitle(FORM_ID), dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID) approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined); }).catch(() => undefined);
}; };
@@ -109,7 +121,12 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey }
<FormCard <FormCard
title="Заявка на отпуск" title="Заявка на отпуск"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={<InitiatorLine listTitle={dataListTitle(FORM_ID)} itemId={itemId} />} belowSubtitle={
<InitiatorLine
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
itemId={itemId}
/>
}
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={status ? <StatusBadge status={status} /> : undefined}
> >
<FormModeProvider readOnly> <FormModeProvider readOnly>
@@ -11,10 +11,17 @@ export interface ILoadedRequest {
code: string; code: string;
} }
/** Прочитать заявку на отпуск по id: модель формы + статус (правка / просмотр). */ /**
export async function loadVacationRequest(itemId: number): Promise<ILoadedRequest> { * Прочитать заявку на отпуск по id: модель формы + статус (правка / просмотр).
const item = (await getSP() *
.web.lists.getByTitle(dataListTitle(FORM_ID)) * listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/
* listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции (как раньше); задан —
* идём напрямую по url списка, а не по имени: так не нужно резолвить, как
* называется архивный список, и запрос не зависит от реестра партиций.
*/
export async function loadVacationRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = (await list
.items.getById(itemId) .items.getById(itemId)
.select( .select(
'Id', 'Id',
@@ -1,18 +1,15 @@
import { getSP } from '../../shared/pnp/pnpjs'; import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval'; import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
import { buildRequestUniqueCode } from '../../shared/requestCode'; import { ensureEmployee } from '../../shared/employee';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData } from './model'; import { FORM_ID, IFormData } from './model';
/** Экземпляр сервиса списка согласования этой формы. */ /**
export function vacationApprovalService(): ApprovalListService { * Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
return new ApprovalListService(approvalListTitle(FORM_ID)); * approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
} * именем формы для читаемости вызывающего кода.
*/
/** email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка). */ export function vacationApprovalService(listId?: string): ApprovalListService {
async function ensureEmployee(login: string): Promise<{ Id: number; Title: string }> { return approvalServiceFor(FORM_ID, listId);
const full = login.indexOf('|') >= 0 ? login : `i:0#.f|membership|${login}`;
return getSP().web.ensureUser(full);
} }
function pad2(n: number): string { function pad2(n: number): string {
@@ -46,42 +43,34 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
}; };
} }
export interface ISaveResult { export type ISaveResult = ISaveItemResult;
id: number;
/** Задан только при создании новой заявки (существующий код не меняется). */
code?: string;
}
/** /**
* Сохранить заявку на отпуск: содержимое + маршрут согласования. * Сохранить заявку на отпуск: содержимое + маршрут согласования.
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Фазы исполнения у отпуска нет — writeRoute вызывается с пустым списком исполнителей. * Фазы исполнения у отпуска нет — writeRoute вызывается с пустым списком исполнителей.
* Возвращает Id заявки (+ код при создании). * Возвращает Id заявки (+ код при создании).
*
* listId — партиция, в которую фактически пишем: при редактировании — партиция
* ОБНОВЛЯЕМОЙ заявки (см. loadVacationRequest), при создании — партиция,
* полученная от approval-route API непосредственно перед вызовом (см.
* getCurrentListPartition в VacationForm.tsx) — именно этот вызов, а не кеш из
* onInit, проверяет порог элементов и запускает архивацию следующей партиции.
* Не задан (сбой запроса за текущей партицией) → откат на список по конвенции
* имён, как до появления партиционирования.
*/ */
export async function saveVacationRequest( export async function saveVacationRequest(
existingId: number | undefined, existingId: number | undefined,
requestTypeId: number, requestTypeId: number,
data: IFormData, data: IFormData,
approvers: IApproverToWrite[], approvers: IApproverToWrite[],
status: string status: string,
listId?: string
): Promise<ISaveResult> { ): Promise<ISaveResult> {
const list = getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const employee = await ensureEmployee(data.Employee); const employee = await ensureEmployee(data.Employee);
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee); const item = buildItem(data, status, employee.Id, employee.Title || data.Employee);
let id: number; const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
let code: string | undefined; await writeRoute(vacationApprovalService(listId), result.id, approvers, [], status);
if (existingId) { return result;
await list.items.getById(existingId).update(item);
id = existingId;
} else {
const added = await list.items.add(item);
id = (added as { Id: number }).Id;
// Код известен только после создания элемента (нужен его Id).
code = buildRequestUniqueCode(requestTypeId, id, new Date());
await list.items.getById(id).update({ RequestUniqueCode: code });
}
await writeRoute(vacationApprovalService(), id, approvers, [], status);
return { id, code };
} }
@@ -20,13 +20,14 @@ import {
StatusBadge, StatusBadge,
useMatrixEditor, useMatrixEditor,
getPlannedMatrix, getPlannedMatrix,
resolveWriteListId,
notifyActiveStep, notifyActiveStep,
useRequestTypeId, useRequestTypeId,
REQUEST_STATUS, REQUEST_STATUS,
isEditableStatus, isEditableStatus,
IApproverToWrite, IApproverToWrite,
APPROVAL_PHASE, APPROVAL_PHASE,
findUnassignedStep unassignedStepMessage
} from '../../shared/approval'; } from '../../shared/approval';
import { import {
uploadAttachments, uploadAttachments,
@@ -36,7 +37,12 @@ import {
} from '../../shared/attachments/attachments.service'; } from '../../shared/attachments/attachments.service';
import { effectiveUserEmail } from '../../shared/impersonation'; import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types'; import { IRequestFormProps } from '../../models/types';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { saveWorkplaceRequest, workplaceApprovalService } from './submit'; import { saveWorkplaceRequest, workplaceApprovalService } from './submit';
import { loadPrefillFromHire } from './prefill'; import { loadPrefillFromHire } from './prefill';
@@ -44,7 +50,15 @@ import { loadWorkplaceRequest } from './load';
import { EmployeeSection } from './sections/EmployeeSection'; import { EmployeeSection } from './sections/EmployeeSection';
import { EquipmentSection } from './sections/EquipmentSection'; import { EquipmentSection } from './sections/EquipmentSection';
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, sourceId, editId }) => { const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
requestType,
context,
onCancel,
onSaved,
sourceId,
editId,
editListId
}) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]); const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]);
@@ -68,7 +82,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
return; return;
} }
let alive = true; let alive = true;
Promise.all([loadWorkplaceRequest(editId), getAttachments(dataListTitle(FORM_ID), editId)]) Promise.all([loadWorkplaceRequest(editId, editListId), getAttachments(editListId || dataListTitle(FORM_ID), editId)])
.then(([loaded, atts]) => { .then(([loaded, atts]) => {
if (alive) { if (alive) {
setData(loaded.data); setData(loaded.data);
@@ -85,7 +99,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
return () => { return () => {
alive = false; alive = false;
}; };
}, [editId]); }, [editId, editListId]);
// Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource). // Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource).
useEffect(() => { useEffect(() => {
@@ -109,7 +123,10 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
}; };
}, [sourceId]); }, [sourceId]);
const approval = useMemo(() => workplaceApprovalService(), []); // editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
// workplaceApprovalService можно звать не опасаясь исключения.
const approval = useMemo(() => workplaceApprovalService(editListId), [editListId]);
const userEmail = effectiveUserEmail(context); const userEmail = effectiveUserEmail(context);
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API. // data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
@@ -181,7 +198,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
if (requestId === undefined) { if (requestId === undefined) {
return; return;
} }
deleteAttachment(dataListTitle(FORM_ID), requestId, fileName) deleteAttachment(editListId || dataListTitle(FORM_ID), requestId, fileName)
.then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName))) .then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName)))
.catch(() => undefined); .catch(() => undefined);
}; };
@@ -194,6 +211,14 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
if (!rtId) { if (!rtId) {
throw new Error('Тип заявки не определён — обновите страницу и повторите.'); throw new Error('Тип заявки не определён — обновите страницу и повторите.');
} }
// requestId уже задан → редактируем существующую заявку, пишем в ту же
// партицию (editListId). Иначе — создаём новую, и партицию для записи
// спрашиваем у approval-route ПРЯМО СЕЙЧАС (а не берём из кеша onInit):
// это единственный запрос, который проверяет порог элементов текущего
// списка и ставит в очередь создание следующей партиции при достижении —
// без него архивация никогда не сработает. Сбой запроса не должен
// блокировать подачу заявки — откатываемся на список по конвенции имён.
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
const { id, code } = await saveWorkplaceRequest( const { id, code } = await saveWorkplaceRequest(
requestId, requestId,
rtId, rtId,
@@ -201,7 +226,8 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
toApprovers(matrix.rows), toApprovers(matrix.rows),
toApprovers(executors.rows), toApprovers(executors.rows),
status, status,
sourceId sourceId,
writeListId
); );
setRequestId(id); setRequestId(id);
setRequestStatus(status); setRequestStatus(status);
@@ -209,19 +235,22 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
setRequestCode(code); setRequestCode(code);
} }
if (files.length > 0) { if (files.length > 0) {
await uploadAttachments(dataListTitle(FORM_ID), id, files); await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files);
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
setExistingAttachments(await getAttachments(dataListTitle(FORM_ID), id)); // показать добавленные setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id)); // показать добавленные
} }
if (onSaved) { if (onSaved) {
onSaved(); onSaved();
} }
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения). // Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
// Имена списков — той же партиции, куда фактически записали (writeListId),
// не обязательно текущей на момент рендера формы: поток ищет строку
// маршрута именно в НЕЙ.
if (status === REQUEST_STATUS.onApproval) { if (status === REQUEST_STATUS.onApproval) {
await notifyActiveStep(context, approval, id, { await notifyActiveStep(context, approval, id, {
requestTypeId: rtId, requestTypeId: rtId,
dataListName: dataListTitle(FORM_ID), dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID), approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID),
}); });
} }
}; };
@@ -233,14 +262,16 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
const validateSubmit = (): string | undefined => { const validateSubmit = (): string | undefined => {
const formError = validate(); const formError = validate();
if (formError) return formError; if (formError) return formError;
const unassigned = findUnassignedStep(matrix.rows); const unassignedApproverError = unassignedStepMessage(
if (unassigned) { matrix.rows,
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`; (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
} );
const unassignedExecutor = findUnassignedStep(executors.rows); if (unassignedApproverError) return unassignedApproverError;
if (unassignedExecutor) { const unassignedExecutorError = unassignedStepMessage(
return `Шаг ${unassignedExecutor.order} исполнения («${unassignedExecutor.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`; executors.rows,
} (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`
);
if (unassignedExecutorError) return unassignedExecutorError;
return undefined; return undefined;
}; };
@@ -261,7 +292,12 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
<FormCard <FormCard
title={requestType.title} title={requestType.title}
subtitle="Категория: IT" subtitle="Категория: IT"
belowSubtitle={<InitiatorLine listTitle={dataListTitle(FORM_ID)} itemId={requestId} />} belowSubtitle={
<InitiatorLine
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
itemId={requestId}
/>
}
headerRight={ headerRight={
requestId || requestStatus ? ( requestId || requestStatus ? (
<> <>
@@ -27,7 +27,12 @@ import {
APPROVAL_PHASE APPROVAL_PHASE
} from '../../shared/approval'; } from '../../shared/approval';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles'; import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadWorkplaceRequest } from './load'; import { loadWorkplaceRequest } from './load';
import { workplaceApprovalService, updateReviewFields } from './submit'; import { workplaceApprovalService, updateReviewFields } from './submit';
@@ -39,6 +44,8 @@ interface IWorkplaceSetupViewProps {
itemId: number; itemId: number;
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */ /** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
formKey: string; formKey: string;
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
listId?: string;
} }
type SaveState = 'idle' | 'saving' | 'saved' | 'error'; type SaveState = 'idle' | 'saving' | 'saved' | 'error';
@@ -46,7 +53,7 @@ type SaveState = 'idle' | 'saving' | 'saved' | 'error';
// Заявка «орг. рабочего места» только для просмотра (режим согласующего). // Заявка «орг. рабочего места» только для просмотра (режим согласующего).
// Исключение: первый согласующий шаг (по маршруту — непосредственный руководитель) // Исключение: первый согласующий шаг (по маршруту — непосредственный руководитель)
// на своём этапе может заполнить «Необходимое ПО» и «Доступы» (инициатор их не знает). // на своём этапе может заполнить «Необходимое ПО» и «Доступы» (инициатор их не знает).
const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId, formKey }) => { const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId, formKey, listId }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
@@ -59,9 +66,14 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
const { id: requestTypeId } = useRequestTypeId(context, formKey); const { id: requestTypeId } = useRequestTypeId(context, formKey);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
// workplaceApprovalService можно звать не опасаясь исключения.
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
const dataListRef = listId || dataListTitle(FORM_ID);
const approverService = useMemo( const approverService = useMemo(
() => new ApproverService(workplaceApprovalService(), dataListTitle(FORM_ID)), () => new ApproverService(workplaceApprovalService(listId), dataListRef),
[] [listId, dataListRef]
); );
// Матрица маршрута из фактических строк заявки (read-only), разбитая по фазам. // Матрица маршрута из фактических строк заявки (read-only), разбитая по фазам.
@@ -72,7 +84,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
// перечитывает матрицу. Иначе на экране остались бы данные, загруженные при // перечитывает матрицу. Иначе на экране остались бы данные, загруженные при
// открытии формы, и пользователь видел бы свой шаг всё ещё «На согласовании». // открытии формы, и пользователь видел бы свой шаг всё ещё «На согласовании».
const [decisionSeq, setDecisionSeq] = useState<number>(0); const [decisionSeq, setDecisionSeq] = useState<number>(0);
const approval = useMemo(() => workplaceApprovalService(), []); const approval = useMemo(() => workplaceApprovalService(listId), [listId]);
const loadApprovers = useCallback( const loadApprovers = useCallback(
() => () =>
approval approval
@@ -97,7 +109,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
setLoading(true); setLoading(true);
Promise.all([loadWorkplaceRequest(itemId), getAttachments(dataListTitle(FORM_ID), itemId)]) Promise.all([loadWorkplaceRequest(itemId, listId), getAttachments(listId || dataListTitle(FORM_ID), itemId)])
.then(([loaded, files]) => { .then(([loaded, files]) => {
if (alive) { if (alive) {
setData(loaded.data); setData(loaded.data);
@@ -117,7 +129,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
return () => { return () => {
alive = false; alive = false;
}; };
}, [itemId]); }, [itemId, listId]);
// Правка «ПО»/«Доступы» доступна, когда сейчас очередь ПЕРВОГО шага согласования // Правка «ПО»/«Доступы» доступна, когда сейчас очередь ПЕРВОГО шага согласования
// (по маршруту — непосредственного руководителя) и это текущий пользователь. // (по маршруту — непосредственного руководителя) и это текущий пользователь.
@@ -144,8 +156,8 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
}, [itemId, approval, approverService]); }, [itemId, approval, approverService]);
const saveReviewFields = useCallback( const saveReviewFields = useCallback(
() => updateReviewFields(itemId, { Software: data.Software, AccessRights: data.AccessRights }), () => updateReviewFields(itemId, { Software: data.Software, AccessRights: data.AccessRights }, listId),
[itemId, data.Software, data.AccessRights] [itemId, data.Software, data.AccessRights, listId]
); );
const handleSave = async (): Promise<void> => { const handleSave = async (): Promise<void> => {
@@ -175,8 +187,8 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
if (!requestTypeId) return; if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, { notifyActiveStep(context, approval, itemId, {
requestTypeId, requestTypeId,
dataListName: dataListTitle(FORM_ID), dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID), approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
}).catch(() => undefined); }).catch(() => undefined);
}; };
@@ -186,7 +198,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
{isEditableStatus(status) && ( {isEditableStatus(status) && (
<Button <Button
variant="secondary" variant="secondary"
onClick={() => navigate({ mode: 'edit', nav: 'new', formKey, itemId })} onClick={() => navigate({ mode: 'edit', nav: 'new', formKey, itemId, listId })}
> >
<Icon name="edit" size="sm" /> <Icon name="edit" size="sm" />
Редактировать Редактировать
@@ -212,7 +224,12 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
<FormCard <FormCard
title="Организация рабочего места" title="Организация рабочего места"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={<InitiatorLine listTitle={dataListTitle(FORM_ID)} itemId={itemId} />} belowSubtitle={
<InitiatorLine
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
itemId={itemId}
/>
}
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={status ? <StatusBadge status={status} /> : undefined}
> >
<FormModeProvider readOnly> <FormModeProvider readOnly>
@@ -14,10 +14,17 @@ export interface ILoadedRequest {
code: string; code: string;
} }
/** Прочитать заявку по id: модель формы + статус (для режима чтения). */ /**
export async function loadWorkplaceRequest(itemId: number): Promise<ILoadedRequest> { * Прочитать заявку по id: модель формы + статус (для режима чтения).
const item = (await getSP() *
.web.lists.getByTitle(dataListTitle(FORM_ID)) * listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/
* listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции (как раньше); задан —
* идём напрямую по url списка, а не по имени: так не нужно резолвить, как
* называется архивный список, и запрос не зависит от реестра партиций.
*/
export async function loadWorkplaceRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = (await list
.items.getById(itemId) .items.getById(itemId)
.select(...SCALAR_FIELDS, 'NeedsEquipment', 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>; .select(...SCALAR_FIELDS, 'NeedsEquipment', 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>;
@@ -1,23 +1,30 @@
import { getSP } from '../../shared/pnp/pnpjs'; import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval'; import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList';
import { buildRequestUniqueCode } from '../../shared/requestCode';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData } from './model'; import { FORM_ID, IFormData } from './model';
/** Экземпляр сервиса списка согласования этой формы. */ /**
export function workplaceApprovalService(): ApprovalListService { * Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
return new ApprovalListService(approvalListTitle(FORM_ID)); * approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
* именем формы для читаемости вызывающего кода.
*/
export function workplaceApprovalService(listId?: string): ApprovalListService {
return approvalServiceFor(FORM_ID, listId);
} }
/** /**
* Обновить поля, которые заполняет согласующий-руководитель на этапе согласования * Обновить поля, которые заполняет согласующий-руководитель на этапе согласования
* (инициатор часто не знает нужное ПО и доступы). Пишет только эти два поля. * (инициатор часто не знает нужное ПО и доступы). Пишет только эти два поля.
*
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadWorkplaceRequest): элемент уже
* существует, поэтому читаем/пишем напрямую по url его списка, если он задан.
*/ */
export async function updateReviewFields( export async function updateReviewFields(
id: number, id: number,
fields: { Software: string; AccessRights: string } fields: { Software: string; AccessRights: string },
listId?: string
): Promise<void> { ): Promise<void> {
await getSP().web.lists.getByTitle(dataListTitle(FORM_ID)).items.getById(id).update({ const list = resolveDataList(FORM_ID, listId);
await list.items.getById(id).update({
Software: fields.Software === '' ? null : fields.Software, Software: fields.Software === '' ? null : fields.Software,
AccessRights: fields.AccessRights === '' ? null : fields.AccessRights, AccessRights: fields.AccessRights === '' ? null : fields.AccessRights,
}); });
@@ -48,17 +55,21 @@ function buildItem(data: IFormData, status: string, sourceHireId?: number): Reco
return item; return item;
} }
export interface ISaveResult { export type ISaveResult = ISaveItemResult;
id: number;
/** Задан только при создании новой заявки (существующий код не меняется). */
code?: string;
}
/** /**
* Сохранить заявку «орг. рабочего места»: контент + маршрут (согласующие + исполнители). * Сохранить заявку «орг. рабочего места»: контент + маршрут (согласующие + исполнители).
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Исполнители пишутся сразу — фаза исполнения стартует автоматически после согласования. * Исполнители пишутся сразу — фаза исполнения стартует автоматически после согласования.
* sourceHireId — если заявка создана на основе «прием». Возвращает Id заявки (+ код при создании). * sourceHireId — если заявка создана на основе «прием». Возвращает Id заявки (+ код при создании).
*
* listId — партиция, в которую фактически пишем: при редактировании — партиция
* ОБНОВЛЯЕМОЙ заявки (см. loadWorkplaceRequest), при создании — партиция,
* полученная от approval-route API непосредственно перед вызовом (см.
* getCurrentListPartition в WorkplaceSetupForm.tsx) — именно этот вызов, а не
* кеш из onInit, проверяет порог элементов и запускает архивацию следующей
* партиции. Не задан (сбой запроса за текущей партицией) → откат на список по
* конвенции имён, как до появления партиционирования.
*/ */
export async function saveWorkplaceRequest( export async function saveWorkplaceRequest(
existingId: number | undefined, existingId: number | undefined,
@@ -67,24 +78,12 @@ export async function saveWorkplaceRequest(
approvers: IApproverToWrite[], approvers: IApproverToWrite[],
executors: IApproverToWrite[], executors: IApproverToWrite[],
status: string, status: string,
sourceHireId?: number sourceHireId?: number,
listId?: string
): Promise<ISaveResult> { ): Promise<ISaveResult> {
const list = getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = buildItem(data, status, sourceHireId); const item = buildItem(data, status, sourceHireId);
let id: number; const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
let code: string | undefined; await writeRoute(workplaceApprovalService(listId), result.id, approvers, executors, status);
if (existingId) { return result;
await list.items.getById(existingId).update(item);
id = existingId;
} else {
const added = await list.items.add(item);
id = (added as { Id: number }).Id;
// Код известен только после создания элемента (нужен его Id).
code = buildRequestUniqueCode(requestTypeId, id, new Date());
await list.items.getById(id).update({ RequestUniqueCode: code });
}
await writeRoute(workplaceApprovalService(), id, approvers, executors, status);
return { id, code };
} }
@@ -11,6 +11,12 @@ export interface IApprovalViewProps {
* подачи получает готовый requestType.id, а View — только itemId/context). * подачи получает готовый requestType.id, а View — только itemId/context).
*/ */
formKey: string; formKey: string;
/**
* Партиция (серверный url списка), где лежит именно ЭТА заявка — см.
* shared/approval/listPartitions.ts. Не задан — просмотр откатывается на
* текущую партицию типа заявки (старые ссылки без параметра).
*/
listId?: string;
} }
type ViewComponent = React.ComponentType<IApprovalViewProps>; type ViewComponent = React.ComponentType<IApprovalViewProps>;
@@ -44,4 +44,13 @@ export interface IRequestFormProps {
* согласования читается из уже записанных строк, а не планируется заново. * согласования читается из уже записанных строк, а не планируется заново.
*/ */
editId?: number; editId?: number;
/**
* Партиция (серверный url списка), где лежит editId — см.
* shared/approval/listPartitions.ts. Заявки нумерованные: без этого поля
* форма при редактировании всегда читала/писала бы в ТЕКУЩУЮ партицию, и
* архивная заявка правилась бы не туда. Не задан — либо editId тоже не
* задан (новая заявка), либо ссылка старого формата без параметра — тогда
* форма откатывается на текущую партицию.
*/
editListId?: string;
} }
@@ -381,6 +381,7 @@ export class ApprovalListService {
const batchedItems = batchedSP.web.lists.getByTitle(this.listTitle).items; const batchedItems = batchedSP.web.lists.getByTitle(this.listTitle).items;
rows.forEach((row) => { rows.forEach((row) => {
// void — по той же причине, что и в createSteps выше. // void — по той же причине, что и в createSteps выше.
// eslint-disable-next-line no-void -- без void это «висячий» промис, а no-floating-promises здесь уровня error
void batchedItems.getById(row.id).delete(); void batchedItems.getById(row.id).delete();
}); });
await execute(); await execute();
@@ -1,7 +1,15 @@
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http'; import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base'; import { WebPartContext } from '@microsoft/sp-webpart-base';
import { APPROVAL_API_CLIENT_ID, APPROVAL_API_BASE_URL } from '../config'; import { APPROVAL_API_CLIENT_ID, getApprovalApiBaseUrl } from '../config';
import { ApprovalStatus, IApiResponse, IRequestType, IResolvedRoute, IResolveFieldValue } from './models/IApprovalRouteApi'; import {
ApprovalStatus,
ICurrentListPartition,
IApiResponse,
IRequestType,
IRequestTypeListSeries,
IResolvedRoute,
IResolveFieldValue
} from './models/IApprovalRouteApi';
// Все обращения к внешнему approval-route API — только здесь. // Все обращения к внешнему approval-route API — только здесь.
// Клиент авторизуется через AadHttpClient (SSO по текущему пользователю). // Клиент авторизуется через AadHttpClient (SSO по текущему пользователю).
@@ -14,7 +22,7 @@ async function client(context: WebPartContext): Promise<AadHttpClient> {
export async function getRequestTypes(context: WebPartContext): Promise<IRequestType[]> { export async function getRequestTypes(context: WebPartContext): Promise<IRequestType[]> {
const c = await client(context); const c = await client(context);
const response: HttpClientResponse = await c.get( const response: HttpClientResponse = await c.get(
`${APPROVAL_API_BASE_URL}/api/request-types`, `${getApprovalApiBaseUrl()}/api/request-types`,
AadHttpClient.configurations.v1 AadHttpClient.configurations.v1
); );
if (!response.ok) { if (!response.ok) {
@@ -24,6 +32,53 @@ export async function getRequestTypes(context: WebPartContext): Promise<IRequest
return body.data ?? []; return body.data ?? [];
} }
/**
* GET /api/request-types/lists — серии партиций сразу по ВСЕМ типам заявок.
*
* Именно bulk-эндпоинт, а не по одному на тип: партиции нужны и для записи (куда
* писать), и для поиска (по каким спискам искать), причём поиск идёт сразу по
* всем типам — запрашивать серию отдельно на каждый тип означало бы N запросов
* на каждое открытие «Моих заявок».
*
* Типы без настроенного списка SharePoint в ответ не попадают.
*/
export async function getListPartitions(context: WebPartContext): Promise<IRequestTypeListSeries[]> {
const c = await client(context);
const response: HttpClientResponse = await c.get(
`${getApprovalApiBaseUrl()}/api/request-types/lists`,
AadHttpClient.configurations.v1
);
if (!response.ok) {
throw new Error(`request-types/lists: ${response.status} ${response.statusText}`);
}
const body: IApiResponse<IRequestTypeListSeries[]> = await response.json();
return body.data ?? [];
}
/**
* GET /api/request-types/{id}/lists/current — партиция, в которую нужно писать
* НОВУЮ заявку этого типа. Вызывать перед КАЖДЫМ созданием, не кешировать: этот
* запрос заодно проверяет порог элементов на бэкенде и ставит в очередь
* создание следующей партиции при достижении — без него архивация никогда не
* запустится, сколько бы заявок ни создали (bulk-эндпоинт lists, который
* портал и так грузит при onInit, только читает реестр и порог не проверяет).
*/
export async function getCurrentListPartition(
context: WebPartContext,
requestTypeId: number
): Promise<ICurrentListPartition> {
const c = await client(context);
const response: HttpClientResponse = await c.get(
`${getApprovalApiBaseUrl()}/api/request-types/${requestTypeId}/lists/current`,
AadHttpClient.configurations.v1
);
if (!response.ok) {
throw new Error(`request-types/${requestTypeId}/lists/current: ${response.status} ${response.statusText}`);
}
const body: IApiResponse<ICurrentListPartition> = await response.json();
return body.data;
}
// Кеш на уровне МОДУЛЯ (не useRef/useState) — список типов заявок админ меняет // Кеш на уровне МОДУЛЯ (не useRef/useState) — список типов заявок админ меняет
// редко, а без кеша каждая форма и каждый переход в режим согласующего дёргали // редко, а без кеша каждая форма и каждый переход в режим согласующего дёргали
// бы /api/request-types заново. Живёт до перезагрузки страницы — тот же принцип, // бы /api/request-types заново. Живёт до перезагрузки страницы — тот же принцип,
@@ -77,12 +132,14 @@ export async function resolveRoute(
requestTypeId: number, requestTypeId: number,
userEmail: string, userEmail: string,
status: ApprovalStatus = 'Approval', status: ApprovalStatus = 'Approval',
// eslint-disable-next-line @rushstack/no-new-null -- null отправляется в теле запроса как «не задано», undefined в JSON.stringify пропал бы вовсе
departmentId: string | null = null, departmentId: string | null = null,
fieldValues: IResolveFieldValue[] = [], fieldValues: IResolveFieldValue[] = [],
// eslint-disable-next-line @rushstack/no-new-null -- см. departmentId
projectId: string | null = null projectId: string | null = null
): Promise<IResolvedRoute> { ): Promise<IResolvedRoute> {
const c = await client(context); const c = await client(context);
const url = `${APPROVAL_API_BASE_URL}/api/template-routes/resolve`; const url = `${getApprovalApiBaseUrl()}/api/template-routes/resolve`;
// Эндпоинт сменился с GET (query) на POST (JSON-тело). // Эндпоинт сменился с GET (query) на POST (JSON-тело).
// projectId (uuid проекта) — для форм, где согласующий зависит от проекта // projectId (uuid проекта) — для форм, где согласующий зависит от проекта
@@ -0,0 +1,24 @@
import { RequestTypeId } from '../../models/types';
import { approvalListTitle, approvalListTitleForListId } from '../listTitles';
import { ApprovalListService } from './approvalList.service';
/**
* Экземпляр сервиса списка согласования формы.
*
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
* архивной партиции имя приходится резолвить через реестр — если реестр не
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
* а не тихо откатится на текущую партицию.
*
* Раньше каждая форма объявляла свою «xApprovalService» с телом-копией этой
* функции (8 одинаковых реализаций) — оставлены только тонкие обёртки под
* именем формы (см. submit.ts каждой формы), чтобы вызывающий код не терял
* читаемость («vacationApprovalService» вместо безликого параметра formId).
*/
export function approvalServiceFor(formId: RequestTypeId, listId?: string): ApprovalListService {
return new ApprovalListService(
listId ? approvalListTitleForListId(formId, listId) : approvalListTitle(formId)
);
}
@@ -15,8 +15,10 @@ export async function getPlannedMatrix(
userEmail: string, userEmail: string,
approval: ApprovalListService, approval: ApprovalListService,
status: ApprovalStatus = 'Approval', status: ApprovalStatus = 'Approval',
// eslint-disable-next-line @rushstack/no-new-null -- транзитом в resolveRoute(), см. её departmentId
departmentId?: string | null, departmentId?: string | null,
fieldValues?: IResolveFieldValue[], fieldValues?: IResolveFieldValue[],
// eslint-disable-next-line @rushstack/no-new-null -- см. departmentId
projectId?: string | null projectId?: string | null
): Promise<IMatrixRow[]> { ): Promise<IMatrixRow[]> {
if (!userEmail) return []; if (!userEmail) return [];
@@ -36,6 +38,7 @@ export async function getPlannedMatrix(
phase, phase,
slaHours: s.slaHours, slaHours: s.slaHours,
askOverdueReason: s.askOverdueReason, askOverdueReason: s.askOverdueReason,
warning: s.warning,
})) }))
); );
} }
@@ -1,6 +1,7 @@
import { getSP } from '../pnp/pnpjs'; import { getSP } from '../pnp/pnpjs';
import '@pnp/sp/webs'; import '@pnp/sp/webs';
import '@pnp/sp/lists'; import '@pnp/sp/lists';
import { IList } from '@pnp/sp/lists';
import '@pnp/sp/items'; import '@pnp/sp/items';
import { ApprovalListService } from './approvalList.service'; import { ApprovalListService } from './approvalList.service';
import { REQUEST_STATUS, APPROVAL_PHASE } from './models/statuses'; import { REQUEST_STATUS, APPROVAL_PHASE } from './models/statuses';
@@ -33,9 +34,17 @@ const ROW_STATUS: Record<ApproverDecision, string> = {
// заявке (requestId). Пока в портале нет маршрутизации на форму заявки, этот сервис // заявке (requestId). Пока в портале нет маршрутизации на форму заявки, этот сервис
// готов, но UI-режим согласующего ещё не подключён (см. план). // готов, но UI-режим согласующего ещё не подключён (см. план).
export class ApproverService { export class ApproverService {
/**
* dataListRef — либо Title списка данных (текущая партиция, как раньше), либо
* его серверный относительный url (конкретная, в т.ч. архивная партиция —
* см. shared/listTitles.ts). Различаем по ведущему '/': Title списка в
* SharePoint с него не начинается, url — всегда начинается. Так конструктору
* не нужен третий параметр «что именно передали» — вызывающая сторона просто
* передаёт то, что у неё есть (см. submit.ts каждой формы).
*/
constructor( constructor(
private readonly approval: ApprovalListService, private readonly approval: ApprovalListService,
private readonly dataListTitle: string private readonly dataListRef: string
) {} ) {}
// Ожидающая строка текущего пользователя, если сейчас его очередь; иначе undefined. // Ожидающая строка текущего пользователя, если сейчас его очередь; иначе undefined.
@@ -162,8 +171,14 @@ export class ApproverService {
return REQUEST_STATUS.execution; return REQUEST_STATUS.execution;
} }
private dataList(): IList {
return this.dataListRef.startsWith('/')
? getSP().web.getList(this.dataListRef)
: getSP().web.lists.getByTitle(this.dataListRef);
}
private async updateRequestStatus(id: number, status: string): Promise<void> { private async updateRequestStatus(id: number, status: string): Promise<void> {
await getSP().web.lists.getByTitle(this.dataListTitle).items.getById(id).update({ await this.dataList().items.getById(id).update({
RequestStatus: status, RequestStatus: status,
}); });
} }
@@ -13,23 +13,44 @@ export type { IMatrixRow } from './models/IMatrixRow';
export type { IApprovalRow } from './models/IApprovalRow'; export type { IApprovalRow } from './models/IApprovalRow';
export type { export type {
ApprovalStatus, ApprovalStatus,
IListPartition,
ICurrentListPartition,
IRequestType, IRequestType,
IRequestTypeListSeries,
IResolvedRoute, IResolvedRoute,
IResolvedRouteStep, IResolvedRouteStep,
IResolveFieldValue, IResolveFieldValue,
} from './models/IApprovalRouteApi'; } from './models/IApprovalRouteApi';
export { getRequestTypes, getRequestTypeIdByPageUrl, resolveRoute } from './approvalRoute.service'; export {
getRequestTypes,
getRequestTypeIdByPageUrl,
getListPartitions,
getCurrentListPartition,
resolveRoute
} from './approvalRoute.service';
export {
initListPartitions,
listPartitionsLoaded,
partitionsForTypeId,
currentPartitionForTypeId,
currentPartitionForPageUrl,
partitionForPageUrlAndListId,
isConfirmedStaleListId,
registerCurrentPartition,
} from './listPartitions';
export { resolveWriteListId } from './writeListId';
export { useRequestTypeId } from './useRequestTypeId'; export { useRequestTypeId } from './useRequestTypeId';
export type { IRequestTypeIdState } from './useRequestTypeId'; export type { IRequestTypeIdState } from './useRequestTypeId';
export { ApprovalListService } from './approvalList.service'; export { ApprovalListService } from './approvalList.service';
export type { IApproverToWrite } from './approvalList.service'; export type { IApproverToWrite } from './approvalList.service';
export { approvalServiceFor } from './approvalServiceFactory';
export { ApproverService } from './approverDecision.service'; export { ApproverService } from './approverDecision.service';
export type { ApproverDecision, IPendingRow } from './approverDecision.service'; export type { ApproverDecision, IPendingRow } from './approverDecision.service';
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service'; export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
export { notifyActiveStep } from './notify.service'; export { notifyActiveStep } from './notify.service';
export type { INotifyTarget } from './notify.service'; export type { INotifyTarget } from './notify.service';
export { useMatrixEditor, findUnassignedStep } from './useMatrixEditor'; export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
export type { IUseMatrixEditor } from './useMatrixEditor'; export type { IUseMatrixEditor } from './useMatrixEditor';
export { ApprovalMatrix } from './ui/ApprovalMatrix'; export { ApprovalMatrix } from './ui/ApprovalMatrix';
export { UserPicker } from './ui/UserPicker'; export { UserPicker } from './ui/UserPicker';
@@ -0,0 +1,169 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { getListPartitions, getRequestTypes } from './approvalRoute.service';
import { ICurrentListPartition, IListPartition } from './models/IApprovalRouteApi';
/**
* Реестр партиций списков — единственное место, которое знает, куда ПИСАТЬ и где
* ИСКАТЬ заявки каждого типа.
*
* Заполняется один раз при старте web part (initListPartitions в onInit), дальше
* читается синхронно. Синхронные аксессоры принципиальны: имена списков нужны в
* пропсах JSX и в useMemo форм (см. shared/listTitles.ts), а формы — это лениво
* подгружаемые чанки, которые монтируются заведомо позже onInit. Тот же приём,
* что у initSP/getSP и setImpersonatedEmail.
*
* Если approval-route недоступен, реестр остаётся пустым, и вызывающая сторона
* молча откатывается к прежнему поведению «одна партиция по RequestType.listId» —
* портал обязан принимать заявки даже когда админский API лежит.
*/
/** Партиции по ЧИСЛОВОМУ id типа заявки. */
const partitionsByTypeId = new Map<number, IListPartition[]>();
/** pageUrl (слаг формы, напр. 'hr-vacation') → числовой id типа заявки. */
const typeIdByPageUrl = new Map<string, number>();
let loaded = false;
/**
* Загрузить серии партиций и карту слагов. Вызывать один раз в onInit.
*
* Ошибку НЕ пробрасываем: портал должен подняться и работать по прежней
* конвенции имён, даже если approval-route не ответил. Иначе недоступность
* админского API означала бы полностью нерабочий портал.
*/
export async function initListPartitions(context: WebPartContext): Promise<void> {
try {
// Серии — по числовому id, а имена списков в формах — по слагу (pageUrl),
// поэтому нужен и справочник типов, чтобы связать одно с другим.
const [series, types] = await Promise.all([getListPartitions(context), getRequestTypes(context)]);
partitionsByTypeId.clear();
typeIdByPageUrl.clear();
types.forEach((t) => {
if (t.pageUrl) typeIdByPageUrl.set(t.pageUrl, t.id);
});
series.forEach((s) => {
// По номеру, а не в порядке ответа: номер задаёт хронологию серии, и на него
// же опирается выбор «последней» партиции ниже.
const sorted = [...s.partitions].sort((a, b) => a.number - b.number);
partitionsByTypeId.set(s.requestTypeId, sorted);
});
loaded = true;
} catch (e) {
console.warn(
'[Партиции списков] не удалось загрузить серии из approval-route — работаем по конвенции имён с партицией №1',
e
);
}
}
/** Загрузился ли реестр. false — все аксессоры ниже отдают пустоту, и вызывающий использует свой фолбэк. */
export function listPartitionsLoaded(): boolean {
return loaded;
}
/** Все партиции типа заявки (архив + текущая), по возрастанию номера. Пусто — реестр не загружен или тип без списка. */
export function partitionsForTypeId(requestTypeId: number): IListPartition[] {
return partitionsByTypeId.get(requestTypeId) ?? [];
}
/**
* Партиция, в которую идёт запись новых заявок.
*
* Если сервер по какой-то причине не отметил ни одну как текущую, берём с
* наибольшим номером — это та же партиция по смыслу, и лучше записать в неё, чем
* отказать пользователю в создании заявки.
*/
export function currentPartitionForTypeId(requestTypeId: number): IListPartition | undefined {
const partitions = partitionsForTypeId(requestTypeId);
if (partitions.length === 0) return undefined;
return partitions.filter((p) => p.isCurrent)[0] ?? partitions[partitions.length - 1];
}
/** То же, но по слагу формы (pageUrl) — в формах известен именно он, а не числовой id. */
export function currentPartitionForPageUrl(pageUrl: string): IListPartition | undefined {
const id = typeIdByPageUrl.get(pageUrl);
return id === undefined ? undefined : currentPartitionForTypeId(id);
}
/**
* Вписывает в кеш партицию, которую approval-route ТОЛЬКО ЧТО вернул как текущую
* (см. getCurrentListPartition) — вызывать сразу после успешного ответа, ДО того
* как эта партиция понадобится дальше в том же сохранении (approvalListTitleForListId
* и т.п.), а не только при следующей перезагрузке страницы.
*
* Без этого партиция, созданная провижинингом ПОСЛЕ onInit (порог элементов
* достигнут прямо во время этого запроса), была бы неизвестна isConfirmedStaleListId,
* и попытка сохранить в неё же заявку — записав данные в новый список — падала бы
* следующим шагом с «список не найден в реестре партиций», хотя ссылка совершенно
* свежая и рабочая (см. форму пользователя: данные создались, ошибка была только
* в определении имени списка согласования).
*
* Тип, о котором реестр вообще ничего не знает (initListPartitions не ответил при
* старте — typeIdByPageUrl пуст для этого pageUrl), не трогаем: мержить партицию в
* несуществующую серию нечем, а откат на конвенцию имён и так работает без реестра.
*/
export function registerCurrentPartition(pageUrl: string, partition: ICurrentListPartition): void {
const id = typeIdByPageUrl.get(pageUrl);
if (id === undefined) return;
const lower = partition.listId.toLowerCase();
const merged: IListPartition[] = partitionsForTypeId(id)
.filter((p) => p.listId.toLowerCase() !== lower)
.map((p) => ({ ...p, isCurrent: false }));
merged.push({
number: partition.number,
siteUrl: partition.siteUrl,
listId: partition.listId,
title: partition.title,
isCurrent: true,
createdAt: new Date().toISOString()
});
merged.sort((a, b) => a.number - b.number);
partitionsByTypeId.set(id, merged);
}
/**
* Конкретная партиция по её listId — нужна, чтобы открыть/сохранить АРХИВНУЮ
* заявку: тогда известен не номер, а сам listId (пришёл из строки поиска, см.
* IMyRequest.listId в features/myRequests), и его надо сопоставить с записью в
* реестре, чтобы узнать имя списка (для парного списка согласования — см.
* shared/listTitles.ts, approvalListTitleForListId).
*
* undefined — либо реестр про тип вообще ничего не знает (серия пуста), либо
* partitions есть, но именно такого listId среди них нет. Эти два случая —
* РАЗНЫЕ по смыслу: см. isConfirmedStaleListId ниже, только он умеет их различать.
*/
export function partitionForPageUrlAndListId(pageUrl: string, listId: string): IListPartition | undefined {
const id = typeIdByPageUrl.get(pageUrl);
if (id === undefined) return undefined;
const lower = listId.toLowerCase();
return partitionsForTypeId(id).filter((p) => p.listId.toLowerCase() === lower)[0];
}
/**
* true — ТОЛЬКО когда реестр про серию этого типа реально что-то знает (она не
* пуста) и listId в неё не входит. Тогда ссылка гарантированно битая/устаревшая
* (например, партицию удалили из реестра руками) — вот здесь уместна ошибка.
*
* Если же серия пуста — approval-route не ответил при старте, тип только что
* появился, реестр ещё не успел зарегистрировать эту серию — мы просто НЕ ЗНАЕМ,
* валиден ли listId, и вины на нём нет. В этом случае false: тот же приём
* «доверять RequestType.listId без реестра», что и у listTargetsForType в
* myRequests.service.ts — иначе временная недоступность approval-route выглядела
* бы как «эта заявка вообще не открывается», хотя ссылка совершенно рабочая.
*/
export function isConfirmedStaleListId(pageUrl: string, listId: string): boolean {
const id = typeIdByPageUrl.get(pageUrl);
if (id === undefined) return false;
const series = partitionsForTypeId(id);
if (series.length === 0) return false;
const lower = listId.toLowerCase();
return !series.some((p) => p.listId.toLowerCase() === lower);
}
@@ -20,6 +20,53 @@ export interface IApiResponse<T> {
data: T; data: T;
} }
/**
* Одна партиция серии списков типа заявок.
*
* Списки заявок нумерованные: когда в текущем наборе элементов достигается
* порог, approval-route создаёт следующую пару списков («<Форма> <N> — данные» +
* «<Форма> <N> — согласование»), а прежняя пара остаётся архивом только для
* чтения. Писать нужно в партицию с isCurrent, а ИСКАТЬ — по всем сразу,
* иначе заявки из архива просто исчезнут из выдачи.
*/
export interface IListPartition {
/** Номер партиции в серии, с 1. */
number: number;
/** Абсолютный URL сайта, где живёт список этой партиции. */
siteUrl: string;
/** Серверный относительный URL списка данных — та же семантика, что у IRequestType.listId. */
listId: string;
/** Title списка данных на момент создания. null, если approval-route не смог его прочитать. */
title?: string;
/** true — в эту партицию идёт запись новых заявок. Ровно одна в серии. */
isCurrent: boolean;
createdAt: string;
}
// GET /api/request-types/lists → { data: IRequestTypeListSeries[] }
// Серии сразу по всем типам заявок: одним запросом, а не по одному на тип.
export interface IRequestTypeListSeries {
requestTypeId: number;
partitions: IListPartition[];
}
/**
* GET /api/request-types/{id}/lists/current → { data: ICurrentListPartition }
*
* Партиция, в которую нужно писать НОВУЮ заявку. Не чистый GET на бэкенде —
* этот же запрос проверяет порог элементов и при необходимости ставит в
* очередь создание следующей партиции, поэтому его нужно звать перед КАЖДЫМ
* созданием заявки, а не читать из кеша (см. shared/approval/listPartitions.ts,
* который для этого не подходит — грузится один раз при onInit).
*/
export interface ICurrentListPartition {
requestTypeId: number;
number: number;
siteUrl: string;
listId: string;
title?: string;
}
// Условие видимости шага маршрута (настраивается в админке маршрутов — // Условие видимости шага маршрута (настраивается в админке маршрутов —
// «Показывать шаг только при выполнении условия», сравнение «Поле списка»/ // «Показывать шаг только при выполнении условия», сравнение «Поле списка»/
// «Оператор»/«Значение»). fieldName — внутреннее имя колонки списка (напр. // «Оператор»/«Значение»). fieldName — внутреннее имя колонки списка (напр.
@@ -41,11 +88,14 @@ export interface IResolvedRoute {
export interface IResolvedRouteStep { export interface IResolvedRouteStep {
id: number; id: number;
orderNo: number; orderNo: number;
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
executorEmail: string | null; executorEmail: string | null;
// eslint-disable-next-line @rushstack/no-new-null -- см. executorEmail
position: string | null; position: string | null;
allowReassignToOtherApprovers: boolean; allowReassignToOtherApprovers: boolean;
// Предупреждение маршрута по этому шагу (например, роль не разрешилась в // Предупреждение маршрута по этому шагу (например, роль не разрешилась в
// конкретного человека) — приходит от API, показываем автору при желании. // конкретного человека) — приходит от API, показываем автору при желании.
// eslint-disable-next-line @rushstack/no-new-null -- см. executorEmail
warning: string | null; warning: string | null;
// SLA шага в часах и признак опроса причины просрочки. Наследование // SLA шага в часах и признак опроса причины просрочки. Наследование
// «шаг перекрывает тип заявки» бэкенд уже применил — здесь итоговое значение, // «шаг перекрывает тип заявки» бэкенд уже применил — здесь итоговое значение,
@@ -31,4 +31,16 @@ export interface IMatrixRow {
slaHours?: number | null; slaHours?: number | null;
// eslint-disable-next-line @rushstack/no-new-null -- см. slaHours // eslint-disable-next-line @rushstack/no-new-null -- см. slaHours
askOverdueReason?: boolean | null; askOverdueReason?: boolean | null;
/**
* Предупреждение approval-route по ЭТОМУ шагу (см. IResolvedRouteStep.warning) —
* например, роль не разрешилась в конкретного человека, и почему именно.
* Только у ПЛАНИРУЕМОГО маршрута (см. getPlannedMatrix): у уже записанных строк
* (loadXxxRequest) неоткуда взяться — резолв запускался один раз при подаче.
*
* findUnassignedStep показывает его вместо своего общего текста, когда он есть —
* бэкенд знает причину точнее («роль не назначена в справочнике отдела» и т.п.),
* а не просто «нет назначенного человека».
*/
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда (IResolvedRouteStep.warning)
warning?: string | null;
} }
@@ -144,6 +144,17 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
)} )}
<td data-label={roleLabel}> <td data-label={roleLabel}>
{r.who} {r.who}
{/* Причина, по которой approval-route не назначил на этот шаг
конкретного человека (роль не разрешилась и т.п., см.
IResolvedRouteStep.warning/IMatrixRow.warning) — та же строка,
что уходит в ошибку валидации при отправке (см.
unassignedStepMessage), но видна СРАЗУ в таблице, а не только
на попытке отправить: пустая строка «Согласующий» сама по себе
не объясняет, что делать — вручную выбрать человека здесь
нельзя, шаг заведомо требует правки маршрута в админке. */}
{!r.who && r.warning && (
<span className={styles.warningInline}>{r.warning}</span>
)}
{/* Доп. согласующий, добавленный другим согласующим (с правом {/* Доп. согласующий, добавленный другим согласующим (с правом
переназначения) — помечаем, кто его добавил. Пусто у обычных переназначения) — помечаем, кто его добавил. Пусто у обычных
строк маршрута. Как и комментарий — inline под именем, чтобы строк маршрута. Как и комментарий — inline под именем, чтобы
@@ -169,6 +169,19 @@
font-weight: 500; font-weight: 500;
} }
// Причина, по которой approval-route не назначил человека на шаг (см.
// ApprovalMatrix.tsx/r.warning) — красным, как настоящая проблема маршрута
// (в отличие от нейтрального addedByInline/commentInline): пустая строка
// «Согласующий» без этого текста выглядит как баг, а не как «маршрут просит
// вмешательства администратора».
.warningInline {
display: block;
margin-top: 3px;
font-size: $fs-caption;
color: $red-500;
line-height: 1.45;
}
// ВАЖНО: не display:flex — это сняло бы с ячейки display:table-cell, и она // ВАЖНО: не display:flex — это сняло бы с ячейки display:table-cell, и она
// перестала бы вставать в строку рядом с соседней (кнопки уезжали на вторую // перестала бы вставать в строку рядом с соседней (кнопки уезжали на вторую
// строку). Ячейка остаётся табличной, кнопки держатся в строке через nowrap. // строку). Ячейка остаётся табличной, кнопки держатся в строке через nowrap.
@@ -118,6 +118,25 @@ export function findUnassignedStep(rows: IMatrixRow[]): IMatrixRow | undefined {
return rows.filter((r) => !r.email).sort((a, b) => a.order - b.order)[0]; return rows.filter((r) => !r.email).sort((a, b) => a.order - b.order)[0];
} }
/**
* Текст ошибки под первый незанятый шаг — то, что раньше было скопировано в
* validateSubmit каждой формы (findUnassignedStep + `row.warning || <шаблон>`).
* fallback принимает саму строку и решает, что писать, если approval-route не
* прислал свою причину (warning) — у большинства форм это общий шаблон «без
* назначенного человека — выберите …», но у некоторых (см. Ticket1CForm) текст
* другой, поэтому шаблон не зашит сюда, а передаётся вызывающей стороной.
*
* undefined — назначенных не хватает не для этих rows (или все шаги заняты).
*/
export function unassignedStepMessage(
rows: IMatrixRow[],
fallback: (row: IMatrixRow) => string
): string | undefined {
const row = findUnassignedStep(rows);
if (!row) return undefined;
return row.warning || fallback(row);
}
/** /**
* Загружает матрицу согласования и (если editable) позволяет её редактировать. * Загружает матрицу согласования и (если editable) позволяет её редактировать.
* @param load загрузчик строк (планируемый маршрут из resolve или строки заявки). * @param load загрузчик строк (планируемый маршрут из resolve или строки заявки).
@@ -0,0 +1,51 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { RequestTypeId } from '../../models/types';
import { getCurrentListPartition } from './approvalRoute.service';
import { registerCurrentPartition } from './listPartitions';
/**
* Партиция, в которую фактически писать при сохранении заявки.
*
* existingId задан → редактируем существующую заявку, пишем в ту же партицию
* (editListId), запись новых партиций тут ни при чём.
*
* existingId не задан → создаём новую заявку, и партицию для записи спрашиваем
* у approval-route ПРЯМО СЕЙЧАС (а не берём из кеша onInit) — именно этот вызов
* проверяет порог элементов текущего списка и ставит в очередь создание
* следующей партиции при достижении; без него архивация никогда не сработает,
* сколько бы заявок ни создали.
*
* Свежерезолвленная партиция сразу вписывается в локальный кеш
* (registerCurrentPartition): если её создало провижинирование ПРЯМО ЭТИМ
* запросом (порог как раз достигнут), кеш из onInit о ней ещё не знает, и без
* этого следующий же шаг сохранения (обычно резолв имени списка согласования
* через approvalListTitleForListId) упал бы с «список не найден в реестре
* партиций», хотя данные уже успешно записались и ссылка совершенно рабочая —
* ровно так это и проявилось на форме «Обходной лист».
*
* Сбой запроса к approval-route не должен блокировать подачу заявки — откат на
* editListId (для новой заявки это undefined → список по конвенции имён,
* прежнее поведение до появления партиционирования).
*
* Раньше эта логика (try/catch + getCurrentListPartition + registerCurrentPartition)
* была скопирована в save() каждой из 8 форм — при первом же баге в ней пришлось
* чинить один и тот же код в 8 файлах. Вынесена сюда, чтобы чинить один раз.
*/
export async function resolveWriteListId(
context: WebPartContext,
formId: RequestTypeId,
requestTypeId: number,
existingId: number | undefined,
editListId: string | undefined
): Promise<string | undefined> {
if (existingId) return editListId;
try {
const partition = await getCurrentListPartition(context, requestTypeId);
registerCurrentPartition(formId, partition);
return partition.listId;
} catch (e) {
console.error('Не удалось получить текущую партицию списка — используется список по конвенции имён', e);
return editListId;
}
}
@@ -1,4 +1,7 @@
import { getSP } from '../pnp/pnpjs'; import { getSP } from '../pnp/pnpjs';
import '@pnp/sp/lists';
import { IList } from '@pnp/sp/lists';
import '@pnp/sp/items';
import '@pnp/sp/attachments'; import '@pnp/sp/attachments';
export interface IAttachmentInfo { export interface IAttachmentInfo {
@@ -7,6 +10,18 @@ export interface IAttachmentInfo {
url: string; url: string;
} }
/**
* listRef — либо Title списка данных (текущая партиция), либо его серверный
* относительный url (конкретная, в т.ч. архивная партиция — см. shared/
* listTitles.ts). Различаем по ведущему '/', тем же приёмом, что и
* ApproverService.dataListRef — иначе вложения заявки, лежащей в архивной
* партиции, читались/писались бы в список ТЕКУЩЕЙ (там либо нет элемента с
* таким Id, либо, хуже, есть чужой элемент с тем же Id).
*/
function resolveList(listRef: string): IList {
return listRef.startsWith('/') ? getSP().web.getList(listRef) : getSP().web.lists.getByTitle(listRef);
}
/** Прочитать файл как ArrayBuffer (FileReader — совместимо со всеми браузерами). */ /** Прочитать файл как ArrayBuffer (FileReader — совместимо со всеми браузерами). */
function readAsArrayBuffer(file: File): Promise<ArrayBuffer> { function readAsArrayBuffer(file: File): Promise<ArrayBuffer> {
return new Promise<ArrayBuffer>((resolve, reject) => { return new Promise<ArrayBuffer>((resolve, reject) => {
@@ -18,11 +33,8 @@ function readAsArrayBuffer(file: File): Promise<ArrayBuffer> {
} }
/** Вложения элемента списка (для режима согласующего — ссылки на скачивание). */ /** Вложения элемента списка (для режима согласующего — ссылки на скачивание). */
export async function getAttachments(listTitle: string, itemId: number): Promise<IAttachmentInfo[]> { export async function getAttachments(listRef: string, itemId: number): Promise<IAttachmentInfo[]> {
const files = await getSP() const files = await resolveList(listRef).items.getById(itemId).attachmentFiles();
.web.lists.getByTitle(listTitle)
.items.getById(itemId)
.attachmentFiles();
return files.map((f) => ({ fileName: f.FileName, url: f.ServerRelativeUrl })); return files.map((f) => ({ fileName: f.FileName, url: f.ServerRelativeUrl }));
} }
@@ -31,14 +43,14 @@ export async function getAttachments(listTitle: string, itemId: number): Promise
* Вызывать ПОСЛЕ создания элемента (нужен его id). * Вызывать ПОСЛЕ создания элемента (нужен его id).
*/ */
export async function uploadAttachments( export async function uploadAttachments(
listTitle: string, listRef: string,
itemId: number, itemId: number,
files: File[] files: File[]
): Promise<void> { ): Promise<void> {
if (files.length === 0) { if (files.length === 0) {
return; return;
} }
const item = getSP().web.lists.getByTitle(listTitle).items.getById(itemId); const item = resolveList(listRef).items.getById(itemId);
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
const file = files[i]; const file = files[i];
const content = await readAsArrayBuffer(file); const content = await readAsArrayBuffer(file);
@@ -48,13 +60,9 @@ export async function uploadAttachments(
/** Удалить вложение элемента по имени файла (для правки заявки). */ /** Удалить вложение элемента по имени файла (для правки заявки). */
export async function deleteAttachment( export async function deleteAttachment(
listTitle: string, listRef: string,
itemId: number, itemId: number,
fileName: string fileName: string
): Promise<void> { ): Promise<void> {
await getSP() await resolveList(listRef).items.getById(itemId).attachmentFiles.getByName(fileName).delete();
.web.lists.getByTitle(listTitle)
.items.getById(itemId)
.attachmentFiles.getByName(fileName)
.delete();
} }
+21 -1
View File
@@ -16,7 +16,27 @@ export const DATA_SITE_URL = 'https://tnsukz.sharepoint.com/sites/requests';
* в Центре администрирования SharePoint (API access) при развёртывании. * в Центре администрирования SharePoint (API access) при развёртывании.
*/ */
export const APPROVAL_API_CLIENT_ID = '9232d9e2-9807-4753-808f-07250e5474b0'; export const APPROVAL_API_CLIENT_ID = '9232d9e2-9807-4753-808f-07250e5474b0';
export const APPROVAL_API_BASE_URL = 'https://api.tnsu.kz/approval-route';
/** Прод — то же самое значение, что default в свойстве approvalApiBaseUrl веб-части. */
export const APPROVAL_API_BASE_URL_PROD = 'https://api.tnsu.kz/approval-route';
/** Локальный backend — порт из tnsu-approval-route/backend/.../launchSettings.json. */
export const APPROVAL_API_BASE_URL_LOCAL = 'http://localhost:5080';
// Настраивается свойством веб-части approvalApiBaseUrl (селект в панели свойств,
// см. RequestPortalWebPart.render) — тот же приём, что и у debugUserQuery/
// setImpersonatedIdentity (shared/impersonation.ts): модульный синглтон, а не
// проброс параметром через все вызовы approvalRoute.service.ts, потому что эти
// вызовы разбросаны по формам и не имеют общего входа, через который можно
// было бы прокинуть контекст.
let _approvalApiBaseUrl = APPROVAL_API_BASE_URL_PROD;
export function setApprovalApiBaseUrl(url: string): void {
_approvalApiBaseUrl = url || APPROVAL_API_BASE_URL_PROD;
}
export function getApprovalApiBaseUrl(): string {
return _approvalApiBaseUrl;
}
/** /**
* Справочники (Dictionary API) — тот же gateway api.tnsu.kz и тот же AAD-app * Справочники (Dictionary API) — тот же gateway api.tnsu.kz и тот же AAD-app
@@ -0,0 +1,62 @@
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
import { IList } from '@pnp/sp/lists';
import { getSP } from './pnp/pnpjs';
import { buildRequestUniqueCode } from './requestCode';
import { dataListTitle } from './listTitles';
import { RequestTypeId } from '../models/types';
/**
* Список данных, в который фактически пишем: listId (конкретная партиция —
* либо ОБНОВЛЯЕМОЙ заявки, либо только что резолвленная текущая, см.
* getCurrentListPartition) или, если он не задан, список по конвенции имён
* (см. dataListTitle) — тот же откат, что действовал до партиционирования.
*
* Раньше эта строка (`listId ? getSP().web.getList(listId) : ...`) была
* скопирована в save/update-функции каждой формы и в submit.ts отдельных
* форм (Ticket1C — Planner, Clearance — updateApproverFields) — 8+ мест с
* одинаковым кодом. Расхождение здесь означало бы, что часть операций тихо
* пишет не в ту партицию.
*/
export function resolveDataList(formId: RequestTypeId, listId?: string): IList {
return listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(formId));
}
export interface ISaveItemResult {
id: number;
/** Задан только при создании новой заявки (существующий код не меняется). */
code?: string;
}
/**
* Общее ядро сохранения элемента списка данных: резолв списка, создание/обновление,
* генерация уникального кода заявки. Вынесено из saveXRequest каждой формы — сам
* набор полей (buildItem) и работа с маршрутом (writeRoute) остаются у формы,
* форма отличается только ими.
*
* existingId задан → обновляем ту же запись, иначе создаём новую.
* codePrefix — обычно числовой RequestTypeId, но может быть строкой-меткой
* (см. buildRequestUniqueCode, TICKET1C_CODE_PREFIX).
*/
export async function saveRequestItem(
formId: RequestTypeId,
existingId: number | undefined,
listId: string | undefined,
codePrefix: number | string,
item: Record<string, unknown>
): Promise<ISaveItemResult> {
const list = resolveDataList(formId, listId);
if (existingId) {
await list.items.getById(existingId).update(item);
return { id: existingId };
}
const added = await list.items.add(item);
const id = (added as { Id: number }).Id;
// Код известен только после создания элемента (нужен его Id).
const code = buildRequestUniqueCode(codePrefix, id, new Date());
await list.items.getById(id).update({ RequestUniqueCode: code });
return { id, code };
}
@@ -0,0 +1,13 @@
import '@pnp/sp/webs';
import '@pnp/sp/site-users/web';
import { getSP } from './pnp/pnpjs';
/**
* email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка).
* Было скопировано в submit.ts нескольких форм с сотрудником-инициатором
* (Vacation, Clearance) — вынесено сюда, чтобы не разойтись при правке.
*/
export async function ensureEmployee(login: string): Promise<{ Id: number; Title: string }> {
const full = login.indexOf('|') >= 0 ? login : `i:0#.f|membership|${login}`;
return getSP().web.ensureUser(full);
}
+94 -12
View File
@@ -1,4 +1,9 @@
import { RequestTypeId } from '../models/types'; import { RequestTypeId } from '../models/types';
import {
currentPartitionForPageUrl,
isConfirmedStaleListId,
partitionForPageUrlAndListId
} from './approval/listPartitions';
/** /**
* ЕДИНЫЙ источник имён списков SharePoint. * ЕДИНЫЙ источник имён списков SharePoint.
@@ -32,15 +37,19 @@ const LIST_TITLE_BASE: Record<RequestTypeId, string> = {
}; };
/** /**
* Номер текущей пары списков. Пока единый для всех типов заявок — архивных пар ещё * Номер партиции, используемый КОГДА РЕЕСТР НЕДОСТУПЕН (approval-route не ответил
* нет, все формы работают с парой №1. * при старте — см. initListPartitions). Первая пара списков существует всегда,
* * поэтому это безопасный фолбэк: портал продолжает принимать заявки, просто пишет
* Когда архивация заработает, номер будет приходить из approval-route (эндпоинт * в партицию №1.
* «текущий список по типу заявки») и заполняться один раз при старте web part, до
* загрузки чанка любой формы. Сигнатуры функций ниже при этом не меняются — поэтому
* вызовы по формам править второй раз не придётся.
*/ */
const CURRENT_LIST_NUMBER = 1; const FALLBACK_LIST_NUMBER = 1;
/**
* Суффикс списка данных. Копия того же регекса, что в
* features/myRequests/myRequests.service.ts и в админке approval-route — три разных
* места читают имена одних и тех же списков. Меняя здесь, менять и там.
*/
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
function listTitleBase(id: RequestTypeId): string { function listTitleBase(id: RequestTypeId): string {
const base = LIST_TITLE_BASE[id]; const base = LIST_TITLE_BASE[id];
@@ -50,12 +59,85 @@ function listTitleBase(id: RequestTypeId): string {
return base; return base;
} }
/** Имя списка данных заявок этого типа. */ /**
* Имя списка данных, в который идёт ЗАПИСЬ новых заявок этого типа.
*
* Источник правды — реестр партиций (approval-route знает, какая партиция текущая,
* и сам переключает её, когда прежняя добирает порог). Берём именно фактический
* Title из реестра, а не собираем его из базы и номера: так имя не может разойтись
* с тем, что реально лежит в SharePoint (например, если список переименовали).
*
* Ключи LIST_TITLE_BASE совпадают с pageUrl типов заявок в approval-route — по нему
* реестр и находит нужную серию.
*/
export function dataListTitle(id: RequestTypeId): string { export function dataListTitle(id: RequestTypeId): string {
return `${listTitleBase(id)} ${CURRENT_LIST_NUMBER} — данные`; const currentTitle = currentPartitionForPageUrl(id)?.title;
if (currentTitle) return currentTitle;
return `${listTitleBase(id)} ${FALLBACK_LIST_NUMBER} — данные`;
} }
/** Имя списка согласования заявок этого типа. */ /**
* Имя парного списка согласования.
*
* Выводится из имени списка ДАННЫХ, а не собирается независимо: номер партиции у
* пары обязан совпадать, а список данных — единственное, что реестр знает точно
* (approval-route хранит ссылку только на него, согласование ищется по конвенции).
*/
export function approvalListTitle(id: RequestTypeId): string { export function approvalListTitle(id: RequestTypeId): string {
return `${listTitleBase(id)} ${CURRENT_LIST_NUMBER} — согласование`; const dataTitle = dataListTitle(id);
if (DATA_LIST_SUFFIX.test(dataTitle)) {
return `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
}
// Имя списка данных не по конвенции (переименовали вручную) — вывести парное
// имя из него нельзя, собираем по базе, чтобы вернуть хоть что-то осмысленное.
console.warn(
`[Партиции списков] «${dataTitle}» не оканчивается на «— данные» — имя списка согласования собрано по конвенции`
);
return `${listTitleBase(id)} ${FALLBACK_LIST_NUMBER} — согласование`;
}
/**
* Имя списка данных КОНКРЕТНОЙ партиции по её listId — для открытия/сохранения
* заявки, которая не обязательно лежит в текущей партиции (архив).
*
* Бросает ТОЛЬКО когда реестр ТОЧНО знает, что такого listId в серии нет
* (isConfirmedStaleListId) — тогда мы рискуем прочитать/записать не тот список,
* и правильный ответ — явная ошибка, а не молчаливый откат.
*
* Если же реестр про серию просто ничего не знает (approval-route не ответил
* при старте) — это НЕ повод бросать: откатываемся на ту же конвенцию по номеру,
* что и dataListTitle() для текущей партиции без реестра. Сам listId при этом не
* используется как источник данных (данные читаются им напрямую, см. load.ts
* каждой формы) — здесь он нужен только чтобы вывести ИМЯ списка согласования
* по конвенции, а на неё влияет лишь номер партиции, не сам url.
*/
export function dataListTitleForListId(id: RequestTypeId, listId: string): string {
const partition = partitionForPageUrlAndListId(id, listId);
if (partition?.title) return partition.title;
if (isConfirmedStaleListId(id, listId)) {
throw new Error(
`Список «${listId}» не найден в реестре партиций типа заявки «${id}» — возможно, ссылка устарела.`
);
}
// Партиция нашлась, но Title не закеширован (см. dataListTitle), либо реестр
// про серию вообще ничего не знает — оба случая закрываются одним фолбэком.
return `${listTitleBase(id)} ${partition?.number ?? FALLBACK_LIST_NUMBER} — данные`;
}
/** Имя парного списка согласования конкретной партиции — см. dataListTitleForListId. */
export function approvalListTitleForListId(id: RequestTypeId, listId: string): string {
const dataTitle = dataListTitleForListId(id, listId);
if (DATA_LIST_SUFFIX.test(dataTitle)) {
return `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
}
const partition = partitionForPageUrlAndListId(id, listId);
console.warn(
`[Партиции списков] «${dataTitle}» не оканчивается на «— данные» — имя списка согласования собрано по конвенции`
);
return `${listTitleBase(id)} ${partition?.number ?? FALLBACK_LIST_NUMBER} — согласование`;
} }
+16 -2
View File
@@ -52,6 +52,15 @@ export interface IRoute {
formKey?: string; formKey?: string;
/** Id заявки в списке данных — для 'approve' и 'edit'. */ /** Id заявки в списке данных — для 'approve' и 'edit'. */
itemId?: number; itemId?: number;
/**
* Серверный относительный url КОНКРЕТНОЙ партиции, где лежит заявка — для
* 'approve' и 'edit'. Списки заявок нумерованные (см. shared/approval/
* listPartitions.ts): без этого поля заявка всегда открывалась бы так, будто
* лежит в ТЕКУЩЕЙ партиции, и архивная заявка читалась/сохранялась бы не в
* тот список. Не задан — значит партиция неизвестна (старая ссылка без
* параметра), и форма/просмотр откатываются на текущую партицию.
*/
listId?: string;
/** Id исходной заявки для предзаполнения — для 'create' (напр. орг. места на основе прием). */ /** Id исходной заявки для предзаполнения — для 'create' (напр. орг. места на основе прием). */
sourceId?: number; sourceId?: number;
/** Подвкладка «Моих заявок»; задана только при nav='own'. */ /** Подвкладка «Моих заявок»; задана только при nav='own'. */
@@ -263,6 +272,7 @@ export function parseRoute(search: string = window.location.search): IRoute {
const formKey = params.get('rptype') ?? undefined; const formKey = params.get('rptype') ?? undefined;
const idRaw = params.get('rpid'); const idRaw = params.get('rpid');
const itemId = idRaw ? Number(idRaw) : undefined; const itemId = idRaw ? Number(idRaw) : undefined;
const listId = params.get('rplist') ?? undefined;
const sourceRaw = params.get('rpsource'); const sourceRaw = params.get('rpsource');
const sourceId = sourceRaw ? Number(sourceRaw) : undefined; const sourceId = sourceRaw ? Number(sourceRaw) : undefined;
@@ -288,11 +298,11 @@ export function parseRoute(search: string = window.location.search): IRoute {
// страницей: закрыв панель, пользователь должен вернуться ровно в тот же // страницей: закрыв панель, пользователь должен вернуться ровно в тот же
// список и на то же место в нём. // список и на то же место в нём.
if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) { if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) {
return { mode: 'approve', nav, formKey, itemId, ownTab, ownStatus, page, query }; return { mode: 'approve', nav, formKey, itemId, listId, ownTab, ownStatus, page, query };
} }
// Редактирование собственной незавершённой заявки — та же панель, но с формой. // Редактирование собственной незавершённой заявки — та же панель, но с формой.
if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) { if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) {
return { mode: 'edit', nav, formKey, itemId, ownTab, ownStatus, page, query }; return { mode: 'edit', nav, formKey, itemId, listId, ownTab, ownStatus, page, query };
} }
// Создание производной заявки с предзаполнением из исходной. // Создание производной заявки с предзаполнением из исходной.
if (formKey && sourceId && !isNaN(sourceId)) { if (formKey && sourceId && !isNaN(sourceId)) {
@@ -308,6 +318,7 @@ function buildQuery(route: IRoute): string {
params.delete('rpnav'); params.delete('rpnav');
params.delete('rptype'); params.delete('rptype');
params.delete('rpid'); params.delete('rpid');
params.delete('rplist');
params.delete('rpsource'); params.delete('rpsource');
params.delete('rptab'); params.delete('rptab');
params.delete('rpstatus'); params.delete('rpstatus');
@@ -331,6 +342,9 @@ function buildQuery(route: IRoute): string {
params.set('rpmode', route.mode); params.set('rpmode', route.mode);
params.set('rptype', route.formKey); params.set('rptype', route.formKey);
params.set('rpid', String(route.itemId)); params.set('rpid', String(route.itemId));
// Партиция неизвестна (архивная ссылка старого формата, до этого поля) —
// ссылка всё равно валидна, форма/просмотр просто откатятся на текущую.
if (route.listId) params.set('rplist', route.listId);
} else if (route.mode === 'create' && route.formKey && route.sourceId) { } else if (route.mode === 'create' && route.formKey && route.sourceId) {
params.set('rptype', route.formKey); params.set('rptype', route.formKey);
params.set('rpsource', String(route.sourceId)); params.set('rpsource', String(route.sourceId));