Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc8a43ea35 | |||
| c85989230a | |||
| 11811d101f | |||
| 34cab4d03d | |||
| 973a3a40af | |||
| 2b3de32d3f |
@@ -3,6 +3,7 @@ import * as ReactDom from 'react-dom';
|
|||||||
import { Version } from '@microsoft/sp-core-library';
|
import { Version } from '@microsoft/sp-core-library';
|
||||||
import {
|
import {
|
||||||
type IPropertyPaneConfiguration,
|
type IPropertyPaneConfiguration,
|
||||||
|
PropertyPaneDropdown,
|
||||||
PropertyPaneTextField
|
PropertyPaneTextField
|
||||||
} from '@microsoft/sp-property-pane';
|
} from '@microsoft/sp-property-pane';
|
||||||
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
|
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
|
||||||
@@ -14,12 +15,16 @@ 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 { setImpersonatedEmail } from './shared/impersonation';
|
import { setImpersonatedEmail } 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';
|
||||||
|
|
||||||
export interface IRequestPortalWebPartProps {
|
export interface IRequestPortalWebPartProps {
|
||||||
description: string;
|
description: string;
|
||||||
/** Отладочная подмена личности — см. shared/impersonation.ts. На бою пусто. */
|
/** Отладочная подмена личности — см. shared/impersonation.ts. На бою пусто. */
|
||||||
debugUserEmail: string;
|
debugUserEmail: string;
|
||||||
|
/** Куда шлём запросы approval-route (маршруты согласования) — прод или локальный backend. */
|
||||||
|
approvalApiBaseUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequestPortalWebPartProps> {
|
export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequestPortalWebPartProps> {
|
||||||
@@ -31,6 +36,8 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
|||||||
// Именно здесь, а не в onInit: свойство меняется в панели без перезапуска
|
// Именно здесь, а не в onInit: свойство меняется в панели без перезапуска
|
||||||
// веб-части, и render вызывается на каждое такое изменение.
|
// веб-части, и render вызывается на каждое такое изменение.
|
||||||
setImpersonatedEmail(this.properties.debugUserEmail);
|
setImpersonatedEmail(this.properties.debugUserEmail);
|
||||||
|
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,
|
||||||
@@ -51,7 +58,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,6 +149,19 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
|||||||
placeholder: 'employee@tnsu.kz'
|
placeholder: 'employee@tnsu.kz'
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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})` }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,6 +227,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,
|
||||||
// Страница и запрос — часть того же списка под панелью: без них ссылка на
|
// Страница и запрос — часть того же списка под панелью: без них ссылка на
|
||||||
@@ -374,7 +379,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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -387,6 +392,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();
|
||||||
|
|||||||
@@ -11,6 +11,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' — ждёт его решения. */
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import '@pnp/sp/items';
|
|||||||
import '@pnp/sp/site-users/web';
|
import '@pnp/sp/site-users/web';
|
||||||
import '@pnp/sp/search';
|
import '@pnp/sp/search';
|
||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { getRequestTypes, IRequestType, REQUEST_STATUS } from '../../shared/approval';
|
import { getRequestTypes, IRequestType, partitionsForTypeId, REQUEST_STATUS } from '../../shared/approval';
|
||||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
import { IMyRequest } from './models/IMyRequest';
|
import { IMyRequest } from './models/IMyRequest';
|
||||||
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
|
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
|
||||||
@@ -259,6 +259,15 @@ export async function hydratePendingAuthors(
|
|||||||
// произвольные поля связанного элемента, только через Person-поля вроде
|
// произвольные поля связанного элемента, только через Person-поля вроде
|
||||||
// Author; попытка `expand('Ticket').select('Ticket/Status', ...)` падает с 400).
|
// Author; попытка `expand('Ticket').select('Ticket/Status', ...)` падает с 400).
|
||||||
// Author дочитывается позже (hydrateAuthors), только для строк итоговой страницы.
|
// Author дочитывается позже (hydrateAuthors), только для строк итоговой страницы.
|
||||||
|
/**
|
||||||
|
* Обходит ВСЕ партиции типа заявки: у каждой партиции своя пара списков, и строки
|
||||||
|
* маршрута заявки лежат в списке согласования ТОЙ ЖЕ партиции, что и сама заявка.
|
||||||
|
* Читать только текущую партицию означало бы терять из «На согласовании» всё, что
|
||||||
|
* уехало в архив.
|
||||||
|
*
|
||||||
|
* Диагностика (debug) агрегируется по серии: счётчики суммируются, заголовки
|
||||||
|
* списков согласования перечисляются через запятую.
|
||||||
|
*/
|
||||||
async function indexApprovalsForType(
|
async function indexApprovalsForType(
|
||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
type: IRequestType,
|
type: IRequestType,
|
||||||
@@ -274,17 +283,61 @@ async function indexApprovalsForType(
|
|||||||
skipped: false,
|
skipped: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
if (!tokenEmail) return { rows: [], debug };
|
||||||
if (!tokenEmail) return { rows: [], debug };
|
|
||||||
|
|
||||||
const sp = spForSite(type.siteUrl as string, context);
|
// listTargetsForType сам откатывается на RequestType.listId, если реестр
|
||||||
const list = sp.web.getList(type.listId as string);
|
// партиций не загрузился — тогда ведём себя как раньше, по одному списку.
|
||||||
|
const targets = listTargetsForType(type);
|
||||||
|
|
||||||
|
const rows: IMyRequest[] = [];
|
||||||
|
const approvalTitles: string[] = [];
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
const part = await indexApprovalsForPartition(context, type, target, tokenEmail, scope, statuses);
|
||||||
|
|
||||||
|
rows.push(...part.rows);
|
||||||
|
if (part.approvalListTitle) approvalTitles.push(part.approvalListTitle);
|
||||||
|
if (part.approvalSkippedReason && !debug.approvalSkippedReason) {
|
||||||
|
debug.approvalSkippedReason = part.approvalSkippedReason;
|
||||||
|
}
|
||||||
|
if (part.error && !debug.approvalError) debug.approvalError = part.error;
|
||||||
|
debug.approvalRowCount = (debug.approvalRowCount ?? 0) + (part.approvalRowCount ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (approvalTitles.length > 0) debug.approvalListTitle = approvalTitles.join(', ');
|
||||||
|
debug.approvalPendingTickets = rows.length;
|
||||||
|
|
||||||
|
return { rows, debug };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Результат по одной партиции — строки плюс то, что нужно вкатить в общую диагностику типа. */
|
||||||
|
interface IPartitionApprovalResult {
|
||||||
|
rows: IMyRequest[];
|
||||||
|
approvalListTitle?: string;
|
||||||
|
approvalSkippedReason?: string;
|
||||||
|
approvalRowCount?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function indexApprovalsForPartition(
|
||||||
|
context: WebPartContext,
|
||||||
|
type: IRequestType,
|
||||||
|
target: IListTarget,
|
||||||
|
tokenEmail: string,
|
||||||
|
scope: ApprovalScope,
|
||||||
|
statuses: string[]
|
||||||
|
): Promise<IPartitionApprovalResult> {
|
||||||
|
const debug: IPartitionApprovalResult = { rows: [] };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sp = spForSite(target.siteUrl, context);
|
||||||
|
const list = sp.web.getList(target.listId);
|
||||||
|
|
||||||
const dataListInfo = await list.select('Title')();
|
const dataListInfo = await list.select('Title')();
|
||||||
const dataTitle = (dataListInfo.Title as string) || '';
|
const dataTitle = (dataListInfo.Title as string) || '';
|
||||||
if (!DATA_LIST_SUFFIX.test(dataTitle)) {
|
if (!DATA_LIST_SUFFIX.test(dataTitle)) {
|
||||||
debug.approvalSkippedReason = `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`;
|
debug.approvalSkippedReason = `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`;
|
||||||
return { rows: [], debug };
|
return debug;
|
||||||
}
|
}
|
||||||
const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
|
const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
|
||||||
debug.approvalListTitle = approvalListTitle;
|
debug.approvalListTitle = approvalListTitle;
|
||||||
@@ -358,10 +411,7 @@ async function indexApprovalsForType(
|
|||||||
stepStatusByTicket.set(ticketId, (r.ApprovalStatus as string) || '');
|
stepStatusByTicket.set(ticketId, (r.ApprovalStatus as string) || '');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (ticketIds.length === 0) {
|
if (ticketIds.length === 0) return debug;
|
||||||
debug.approvalPendingTickets = 0;
|
|
||||||
return { rows: [], debug };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Заголовок/статус/дата заявки — отдельным запросом к списку ДАННЫХ (см.
|
// Заголовок/статус/дата заявки — отдельным запросом к списку ДАННЫХ (см.
|
||||||
// комментарий у функции: через Lookup-expand это не отдаётся).
|
// комментарий у функции: через Lookup-expand это не отдаётся).
|
||||||
@@ -371,20 +421,19 @@ async function indexApprovalsForType(
|
|||||||
.select('Id', 'Title', 'RequestStatus', 'Created')
|
.select('Id', 'Title', 'RequestStatus', 'Created')
|
||||||
.top(ticketIds.length)()) as Array<Record<string, unknown>>;
|
.top(ticketIds.length)()) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
const rows = ticketRows.map((r) => ({
|
debug.rows = ticketRows.map((r) => ({
|
||||||
...toMyRequest(r, undefined, type, 'approval'),
|
...toMyRequest(r, undefined, type, 'approval', target),
|
||||||
myStepStatus: stepStatusByTicket.get(r.Id as number),
|
myStepStatus: stepStatusByTicket.get(r.Id as number),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
debug.approvalPendingTickets = rows.length;
|
return debug;
|
||||||
return { rows, debug };
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debug.approvalError = (e as Error).message;
|
debug.error = (e as Error).message;
|
||||||
console.error(
|
console.error(
|
||||||
`Мои заявки: не удалось прочитать согласование для «${type.name}»:`,
|
`Мои заявки: не удалось прочитать согласование для «${type.name}» (${target.listId}):`,
|
||||||
debug.approvalError
|
debug.error
|
||||||
);
|
);
|
||||||
return { rows: [], debug };
|
return debug;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,40 +527,49 @@ async function hydrateAuthors(
|
|||||||
const typeById = new Map<number, IRequestType>();
|
const typeById = new Map<number, IRequestType>();
|
||||||
types.forEach((t) => typeById.set(t.id, t));
|
types.forEach((t) => typeById.set(t.id, t));
|
||||||
|
|
||||||
const idsByType = new Map<number, number[]>();
|
// Группируем по ПАРТИЦИИ строки, а не по её типу: каждая строка помнит, из
|
||||||
|
// какого списка её прочитали (IMyRequest.listId), и дочитывать автора надо
|
||||||
|
// оттуда же — в текущей партиции того Id либо нет, либо это другая заявка.
|
||||||
|
const byPartition = new Map<string, { siteUrl: string; listId: string; typeName: string; ids: number[] }>();
|
||||||
rows.forEach((r) => {
|
rows.forEach((r) => {
|
||||||
const ids = idsByType.get(r.requestTypeId) || [];
|
const bucket = byPartition.get(r.listId.toLowerCase()) || {
|
||||||
ids.push(r.id);
|
siteUrl: r.siteUrl,
|
||||||
idsByType.set(r.requestTypeId, ids);
|
listId: r.listId,
|
||||||
|
typeName: typeById.get(r.requestTypeId)?.name || String(r.requestTypeId),
|
||||||
|
ids: [],
|
||||||
|
};
|
||||||
|
bucket.ids.push(r.id);
|
||||||
|
byPartition.set(r.listId.toLowerCase(), bucket);
|
||||||
});
|
});
|
||||||
|
|
||||||
const byKey = new Map<string, { EMail?: string; Title?: string }>();
|
const byKey = new Map<string, { EMail?: string; Title?: string }>();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Array.from(idsByType.entries()).map(async ([requestTypeId, ids]) => {
|
Array.from(byPartition.values()).map(async ({ siteUrl, listId, typeName, ids }) => {
|
||||||
const type = typeById.get(requestTypeId);
|
|
||||||
if (!type) return;
|
|
||||||
try {
|
try {
|
||||||
const sp = spForSite(type.siteUrl as string, context);
|
const sp = spForSite(siteUrl, context);
|
||||||
const authorRows = (await sp.web
|
const authorRows = (await sp.web
|
||||||
.getList(type.listId as string)
|
.getList(listId)
|
||||||
.items.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
|
.items.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
|
||||||
.select('Id', 'Author/EMail', 'Author/Title')
|
.select('Id', 'Author/EMail', 'Author/Title')
|
||||||
.expand('Author')
|
.expand('Author')
|
||||||
.top(ids.length)()) as Array<Record<string, unknown>>;
|
.top(ids.length)()) as Array<Record<string, unknown>>;
|
||||||
authorRows.forEach((r) => {
|
authorRows.forEach((r) => {
|
||||||
byKey.set(
|
byKey.set(
|
||||||
`${requestTypeId}-${r.Id as number}`,
|
requestKey(listId, r.Id as number),
|
||||||
(r.Author as { EMail?: string; Title?: string }) || {}
|
(r.Author as { EMail?: string; Title?: string }) || {}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[Текущие заявки] Не удалось дочитать автора для «${type.name}»:`, (e as Error).message);
|
console.error(
|
||||||
|
`[Текущие заявки] Не удалось дочитать автора для «${typeName}» (${listId}):`,
|
||||||
|
(e as Error).message
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
rows.forEach((r) => {
|
rows.forEach((r) => {
|
||||||
const a = byKey.get(`${r.requestTypeId}-${r.id}`);
|
const a = byKey.get(rowKey(r));
|
||||||
r.authorEmail = a?.EMail || '';
|
r.authorEmail = a?.EMail || '';
|
||||||
r.authorName = a?.Title || '';
|
r.authorName = a?.Title || '';
|
||||||
});
|
});
|
||||||
@@ -609,10 +667,95 @@ function listPathPrefix(type: IRequestType): string {
|
|||||||
return `${listAbsUrl(type).replace(/\/+$/, '')}/`;
|
return `${listAbsUrl(type).replace(/\/+$/, '')}/`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Хит принадлежит типу, только если путь элемента лежит ВНУТРИ списка —
|
/** Один конкретный список серии: куда идти читать и какому префиксу пути он соответствует. */
|
||||||
// сравнение по границе сегмента, не по «голому» началу строки.
|
export interface IListTarget {
|
||||||
|
/** Серверный относительный url списка данных. */
|
||||||
|
listId: string;
|
||||||
|
siteUrl: string;
|
||||||
|
/** Абсолютный префикс пути элементов этого списка, со слешем на конце. */
|
||||||
|
prefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ВСЕ партиции типа заявки (архив + текущая) как цели чтения.
|
||||||
|
*
|
||||||
|
* Заявки одного типа живут не в одном списке, а в нумерованной серии: когда
|
||||||
|
* текущий список добирает порог элементов, approval-route создаёт следующую пару
|
||||||
|
* и переключает запись на неё. Читать нужно по всей серии, иначе заявки из архива
|
||||||
|
* пропадут из выдачи — а RequestType.listId указывает только на ТЕКУЩУЮ партицию.
|
||||||
|
*
|
||||||
|
* Фолбэк на listId, если реестр партиций не загрузился (approval-route не
|
||||||
|
* ответил при старте): тогда всё ведёт себя как раньше — по одному списку.
|
||||||
|
*/
|
||||||
|
export function listTargetsForType(type: IRequestType): IListTarget[] {
|
||||||
|
const partitions = partitionsForTypeId(type.id);
|
||||||
|
if (partitions.length === 0) {
|
||||||
|
return [
|
||||||
|
{ listId: type.listId as string, siteUrl: type.siteUrl as string, prefix: listPathPrefix(type) }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return partitions.map((p) => ({
|
||||||
|
listId: p.listId,
|
||||||
|
siteUrl: p.siteUrl,
|
||||||
|
prefix: `${siteOrigin(p.siteUrl).replace(/\/+$/, '')}${p.listId.replace(/\/+$/, '')}/`
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** KQL-условие Path по всем партициям всех переданных типов. */
|
||||||
|
function pathClauseForTypes(types: IRequestType[]): string {
|
||||||
|
const prefixes = types.reduce<string[]>(
|
||||||
|
(all, t) => all.concat(listTargetsForType(t).map((target) => target.prefix)),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
return `(${prefixes.map((prefix) => `Path:"${prefix}*"`).join(' OR ')})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Партиция, которой принадлежит хит — по префиксу его пути.
|
||||||
|
*
|
||||||
|
* Именно партиция, а не только тип: перечитывать элемент нужно из ТОГО ЖЕ списка,
|
||||||
|
* где его нашёл Search. Один и тот же Id существует в каждой партиции серии
|
||||||
|
* (нумерация элементов в каждом списке SharePoint своя), поэтому «тип + Id» не
|
||||||
|
* идентифицирует заявку однозначно.
|
||||||
|
*/
|
||||||
|
export function targetForPath(path: string, type: IRequestType): IListTarget | undefined {
|
||||||
|
const lowerPath = path.toLowerCase();
|
||||||
|
return listTargetsForType(type).filter((t) => lowerPath.indexOf(t.prefix.toLowerCase()) === 0)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Хит принадлежит типу, только если путь элемента лежит ВНУТРИ одного из его
|
||||||
|
// списков — сравнение по границе сегмента, не по «голому» началу строки.
|
||||||
export function pathBelongsToList(path: string, type: IRequestType): boolean {
|
export function pathBelongsToList(path: string, type: IRequestType): boolean {
|
||||||
return path.toLowerCase().indexOf(listPathPrefix(type).toLowerCase()) === 0;
|
return !!targetForPath(path, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ключ идентичности заявки — ПАРТИЦИЯ + Id, а не тип + Id.
|
||||||
|
*
|
||||||
|
* Нумерация элементов в каждом списке SharePoint своя, поэтому Id 5 существует и
|
||||||
|
* в партиции №1, и в №2 — это разные заявки. Ключ по типу схлопнул бы их в одну.
|
||||||
|
*/
|
||||||
|
function requestKey(listId: string, id: number): string {
|
||||||
|
return `${listId.toLowerCase()}-${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowKey(row: IMyRequest): string {
|
||||||
|
return requestKey(row.listId, row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Партиция, в которую идёт ЗАПИСЬ — она же единственная, где могут появиться
|
||||||
|
* свежие заявки. Нужна довескам по свежести: перебирать ради них весь архив
|
||||||
|
* бессмысленно (там по определению ничего нового), а Search старое и так найдёт.
|
||||||
|
*
|
||||||
|
* RequestType.listId всегда указывает на текущую партицию — approval-route
|
||||||
|
* переключает его, создавая следующую.
|
||||||
|
*/
|
||||||
|
function currentTargetForType(type: IRequestType): IListTarget {
|
||||||
|
const targets = listTargetsForType(type);
|
||||||
|
const current = targets.filter((t) => t.listId.toLowerCase() === (type.listId || '').toLowerCase())[0];
|
||||||
|
return current ?? targets[targets.length - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
// KQL не терпит спецсимволов в свободном терме — оставляем буквы (лат./кир.),
|
// KQL не терпит спецсимволов в свободном терме — оставляем буквы (лат./кир.),
|
||||||
@@ -646,7 +789,7 @@ function freeTextClause(query: string): string | undefined {
|
|||||||
function hitsToEntries(
|
function hitsToEntries(
|
||||||
results: { PrimarySearchResults: unknown[] },
|
results: { PrimarySearchResults: unknown[] },
|
||||||
types: IRequestType[]
|
types: IRequestType[]
|
||||||
): Array<{ id: number; type: IRequestType }> {
|
): ISearchEntry[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return (results.PrimarySearchResults as Array<Record<string, unknown>>)
|
return (results.PrimarySearchResults as Array<Record<string, unknown>>)
|
||||||
.map((r) => ({ id: Number(r.ListItemID), path: String(r.Path || '') }))
|
.map((r) => ({ id: Number(r.ListItemID), path: String(r.Path || '') }))
|
||||||
@@ -654,18 +797,31 @@ function hitsToEntries(
|
|||||||
.map((h) => {
|
.map((h) => {
|
||||||
// Сравнение по границе сегмента (см. listPathPrefix): при «голом»
|
// Сравнение по границе сегмента (см. listPathPrefix): при «голом»
|
||||||
// indexOf хиты List10 доставались типу List1 — тому, что раньше в массиве.
|
// indexOf хиты List10 доставались типу List1 — тому, что раньше в массиве.
|
||||||
const matched = types.filter((t) => pathBelongsToList(h.path, t))[0];
|
//
|
||||||
return matched ? { id: h.id, type: matched } : undefined;
|
// Ищем не только тип, но и КОНКРЕТНУЮ партицию: перечитывать элемент нужно
|
||||||
|
// из того же списка, где его нашёл Search (см. targetForPath).
|
||||||
|
for (const type of types) {
|
||||||
|
const target = targetForPath(h.path, type);
|
||||||
|
if (target) return { id: h.id, type, target };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
})
|
})
|
||||||
.filter((e): e is { id: number; type: IRequestType } => !!e)
|
.filter((e): e is ISearchEntry => !!e)
|
||||||
.filter((e) => {
|
.filter((e) => {
|
||||||
const key = `${e.type.id}-${e.id}`;
|
const key = requestKey(e.target.listId, e.id);
|
||||||
if (seen.has(key)) return false;
|
if (seen.has(key)) return false;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Найденный хит: Id, его тип и партиция, в которой он лежит. */
|
||||||
|
interface ISearchEntry {
|
||||||
|
id: number;
|
||||||
|
type: IRequestType;
|
||||||
|
target: IListTarget;
|
||||||
|
}
|
||||||
|
|
||||||
// Дочитать поля заявок по найденным Id (Search отдаёт не все колонки, и для
|
// Дочитать поля заявок по найденным Id (Search отдаёт не все колонки, и для
|
||||||
// кастомных полей нет гарантии, что они проиндексированы как managed property).
|
// кастомных полей нет гарантии, что они проиндексированы как managed property).
|
||||||
//
|
//
|
||||||
@@ -675,35 +831,53 @@ function hitsToEntries(
|
|||||||
// предел длины URL SharePoint REST.
|
// предел длины URL SharePoint REST.
|
||||||
async function loadByIds(
|
async function loadByIds(
|
||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
entries: Array<{ id: number; type: IRequestType }>,
|
entries: ISearchEntry[],
|
||||||
kind: 'own' | 'approval'
|
kind: 'own' | 'approval'
|
||||||
): Promise<Map<string, IMyRequest>> {
|
): Promise<Map<string, IMyRequest>> {
|
||||||
const idsByType = new Map<IRequestType, number[]>();
|
// Группируем по ПАРТИЦИИ, а не по типу: Id из архивной партиции в текущем
|
||||||
|
// списке либо не найдётся, либо укажет на чужую заявку с тем же Id.
|
||||||
|
const byPartition = new Map<string, { type: IRequestType; target: IListTarget; ids: number[] }>();
|
||||||
entries.forEach((e) => {
|
entries.forEach((e) => {
|
||||||
const ids = idsByType.get(e.type) || [];
|
const key = e.target.listId.toLowerCase();
|
||||||
ids.push(e.id);
|
const bucket = byPartition.get(key) || { type: e.type, target: e.target, ids: [] };
|
||||||
idsByType.set(e.type, ids);
|
bucket.ids.push(e.id);
|
||||||
|
byPartition.set(key, bucket);
|
||||||
});
|
});
|
||||||
|
|
||||||
const byKey = new Map<string, IMyRequest>();
|
const byKey = new Map<string, IMyRequest>();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Array.from(idsByType.entries()).map(async ([type, ids]) => {
|
Array.from(byPartition.values()).map(async ({ type, target, ids }) => {
|
||||||
const sp = spForSite(type.siteUrl as string, context);
|
// Ошибку ловим НА ПАРТИЦИЮ, а не на весь вызов: список серии может быть
|
||||||
const list = sp.web.getList(type.listId as string);
|
// недоступен (удалён, переименован, остался в реестре после смены списка у
|
||||||
|
// типа заявки) — SharePoint отвечает 404 «Файл не найден». Без изоляции
|
||||||
|
// один такой список валил бы ВСЮ выдачу «Моих заявок», и пользователь не
|
||||||
|
// увидел бы ничего вместо «всё, кроме одного архива».
|
||||||
|
try {
|
||||||
|
const sp = spForSite(target.siteUrl, context);
|
||||||
|
const list = sp.web.getList(target.listId);
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
chunk(ids, REST_ID_FILTER_CHUNK).map(async (idsChunk) => {
|
chunk(ids, REST_ID_FILTER_CHUNK).map(async (idsChunk) => {
|
||||||
const rows = (await list.items
|
const rows = (await list.items
|
||||||
.filter(idsChunk.map((id) => `Id eq ${id}`).join(' or '))
|
.filter(idsChunk.map((id) => `Id eq ${id}`).join(' or '))
|
||||||
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
||||||
.expand('Author')
|
.expand('Author')
|
||||||
.top(idsChunk.length)()) as Array<Record<string, unknown>>;
|
.top(idsChunk.length)()) as Array<Record<string, unknown>>;
|
||||||
rows.forEach((r) => {
|
rows.forEach((r) => {
|
||||||
const author = r.Author as { EMail?: string; Title?: string } | undefined;
|
const author = r.Author as { EMail?: string; Title?: string } | undefined;
|
||||||
byKey.set(`${type.id}-${r.Id as number}`, toMyRequest(r, author, type, kind));
|
byKey.set(
|
||||||
});
|
requestKey(target.listId, r.Id as number),
|
||||||
})
|
toMyRequest(r, author, type, kind, target)
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`[Текущие заявки] «${type.name}»: не удалось прочитать список ${target.listId} — заявки из него в выдачу не попадут:`,
|
||||||
|
(e as Error).message
|
||||||
|
);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return byKey;
|
return byKey;
|
||||||
@@ -714,12 +888,17 @@ async function loadByIds(
|
|||||||
// Фильтруем только по AuthorId, без статуса: эта функция возвращает ВСЕ
|
// Фильтруем только по AuthorId, без статуса: эта функция возвращает ВСЕ
|
||||||
// заявки автора, отбор по статусу (если он нужен) делает вызывающая сторона.
|
// заявки автора, отбор по статусу (если он нужен) делает вызывающая сторона.
|
||||||
async function getOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
|
async function getOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
|
||||||
|
// Только ТЕКУЩАЯ партиция: это довесок по свежести, а свежие заявки бывают
|
||||||
|
// только там, куда идёт запись. Архивные придут из Search (он ищет по всей
|
||||||
|
// серии, см. pathClauseForTypes).
|
||||||
|
const target = currentTargetForType(type);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sp = spForSite(type.siteUrl as string, context);
|
const sp = spForSite(target.siteUrl, context);
|
||||||
const me = await sp.web.currentUser.select('Id')();
|
const me = await sp.web.currentUser.select('Id')();
|
||||||
|
|
||||||
const rows = (await sp.web
|
const rows = (await sp.web
|
||||||
.getList(type.listId as string)
|
.getList(target.listId)
|
||||||
.items.filter(`AuthorId eq ${me.Id}`)
|
.items.filter(`AuthorId eq ${me.Id}`)
|
||||||
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
||||||
.expand('Author')
|
.expand('Author')
|
||||||
@@ -733,7 +912,7 @@ async function getOwnRowsForType(context: WebPartContext, type: IRequestType): P
|
|||||||
}
|
}
|
||||||
|
|
||||||
return rows.map((r) =>
|
return rows.map((r) =>
|
||||||
toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own')
|
toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own', target)
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[Текущие заявки] «${type.name}»: не удалось прочитать свои заявки:`, (e as Error).message);
|
console.error(`[Текущие заявки] «${type.name}»: не удалось прочитать свои заявки:`, (e as Error).message);
|
||||||
@@ -763,20 +942,23 @@ async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestTyp
|
|||||||
// не Choice-колонка, кириллицы в значении нет, поэтому $filter здесь надёжен
|
// не Choice-колонка, кириллицы в значении нет, поэтому $filter здесь надёжен
|
||||||
// (в отличие от статуса, см. getOwnRequestsByStatus).
|
// (в отличие от статуса, см. getOwnRequestsByStatus).
|
||||||
async function getRecentlyChangedOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
|
async function getRecentlyChangedOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
|
||||||
|
// Только текущая партиция — см. getOwnRowsForType: недавно изменённое лежит там же.
|
||||||
|
const target = currentTargetForType(type);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sp = spForSite(type.siteUrl as string, context);
|
const sp = spForSite(target.siteUrl, context);
|
||||||
const me = await sp.web.currentUser.select('Id')();
|
const me = await sp.web.currentUser.select('Id')();
|
||||||
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
|
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
|
||||||
|
|
||||||
const rows = (await sp.web
|
const rows = (await sp.web
|
||||||
.getList(type.listId as string)
|
.getList(target.listId)
|
||||||
.items.filter(`AuthorId eq ${me.Id} and Modified ge '${cutoffIso}'`)
|
.items.filter(`AuthorId eq ${me.Id} and Modified ge '${cutoffIso}'`)
|
||||||
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
||||||
.expand('Author')
|
.expand('Author')
|
||||||
.top(OWN_REST_TOP)()) as Array<Record<string, unknown>>;
|
.top(OWN_REST_TOP)()) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
return rows.map((r) =>
|
return rows.map((r) =>
|
||||||
toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own')
|
toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own', target)
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -808,8 +990,8 @@ export async function getOwnRequests(
|
|||||||
[] as IMyRequest[]
|
[] as IMyRequest[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const searchKeys = new Set(searchPage.rows.map((r) => `${r.requestTypeId}-${r.id}`));
|
const searchKeys = new Set(searchPage.rows.map((r) => rowKey(r)));
|
||||||
const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`));
|
const onlyFresh = freshRows.filter((r) => !searchKeys.has(rowKey(r)));
|
||||||
|
|
||||||
const merged = onlyFresh
|
const merged = onlyFresh
|
||||||
.concat(searchPage.rows)
|
.concat(searchPage.rows)
|
||||||
@@ -834,7 +1016,7 @@ export async function searchOwnRequests(
|
|||||||
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
|
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
|
||||||
|
|
||||||
const clauses = [
|
const clauses = [
|
||||||
`(${types.map((t) => `Path:"${listPathPrefix(t)}*"`).join(' OR ')})`,
|
pathClauseForTypes(types),
|
||||||
`AuthorOWSUSER:"${claimsLogin}"`,
|
`AuthorOWSUSER:"${claimsLogin}"`,
|
||||||
];
|
];
|
||||||
const text = freeTextClause(query);
|
const text = freeTextClause(query);
|
||||||
@@ -856,7 +1038,7 @@ export async function searchOwnRequests(
|
|||||||
const entries = hitsToEntries(results, types);
|
const entries = hitsToEntries(results, types);
|
||||||
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
|
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
|
||||||
// Порядок задаёт релевантность поиска, а не порядок ответов REST.
|
// Порядок задаёт релевантность поиска, а не порядок ответов REST.
|
||||||
const rows = entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r);
|
const rows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
|
||||||
|
|
||||||
return { rows, totalRows };
|
return { rows, totalRows };
|
||||||
}
|
}
|
||||||
@@ -880,7 +1062,7 @@ export async function getOwnRequestsByStatus(
|
|||||||
const sp = spForSite(types[0].siteUrl as string, context);
|
const sp = spForSite(types[0].siteUrl as string, context);
|
||||||
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
|
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
|
||||||
const clauses = [
|
const clauses = [
|
||||||
`(${types.map((t) => `Path:"${listPathPrefix(t)}*"`).join(' OR ')})`,
|
pathClauseForTypes(types),
|
||||||
`AuthorOWSUSER:"${claimsLogin}"`,
|
`AuthorOWSUSER:"${claimsLogin}"`,
|
||||||
`${MP_REQUEST_STATUS}:"${status}"`,
|
`${MP_REQUEST_STATUS}:"${status}"`,
|
||||||
];
|
];
|
||||||
@@ -901,7 +1083,7 @@ export async function getOwnRequestsByStatus(
|
|||||||
const totalRows = results.TotalRows || 0;
|
const totalRows = results.TotalRows || 0;
|
||||||
const entries = hitsToEntries(results, types);
|
const entries = hitsToEntries(results, types);
|
||||||
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
|
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
|
||||||
const searchRows = entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r);
|
const searchRows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
|
||||||
|
|
||||||
if (page !== 1 || query.trim()) return { rows: searchRows, totalRows };
|
if (page !== 1 || query.trim()) return { rows: searchRows, totalRows };
|
||||||
|
|
||||||
@@ -912,8 +1094,8 @@ export async function getOwnRequestsByStatus(
|
|||||||
.reduce((acc, r) => acc.concat(r), [] as IMyRequest[])
|
.reduce((acc, r) => acc.concat(r), [] as IMyRequest[])
|
||||||
.filter((r) => r.status === status);
|
.filter((r) => r.status === status);
|
||||||
|
|
||||||
const searchKeys = new Set(searchRows.map((r) => `${r.requestTypeId}-${r.id}`));
|
const searchKeys = new Set(searchRows.map((r) => rowKey(r)));
|
||||||
const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`));
|
const onlyFresh = freshRows.filter((r) => !searchKeys.has(rowKey(r)));
|
||||||
|
|
||||||
const merged = onlyFresh
|
const merged = onlyFresh
|
||||||
.concat(searchRows)
|
.concat(searchRows)
|
||||||
@@ -928,10 +1110,10 @@ export async function getOwnRequestsByStatus(
|
|||||||
// undefined — список заведён не по конвенции, маршрута для него не найти.
|
// undefined — список заведён не по конвенции, маршрута для него не найти.
|
||||||
async function approvalListFor(
|
async function approvalListFor(
|
||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
type: IRequestType
|
target: IListTarget
|
||||||
): Promise<IList | undefined> {
|
): Promise<IList | undefined> {
|
||||||
const sp = spForSite(type.siteUrl as string, context);
|
const sp = spForSite(target.siteUrl, context);
|
||||||
const info = (await sp.web.getList(type.listId as string).select('Title')()) as { Title?: string };
|
const info = (await sp.web.getList(target.listId).select('Title')()) as { Title?: string };
|
||||||
const dataTitle = info.Title || '';
|
const dataTitle = info.Title || '';
|
||||||
if (!DATA_LIST_SUFFIX.test(dataTitle)) return undefined;
|
if (!DATA_LIST_SUFFIX.test(dataTitle)) return undefined;
|
||||||
return sp.web.lists.getByTitle(`${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`);
|
return sp.web.lists.getByTitle(`${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`);
|
||||||
@@ -968,19 +1150,26 @@ export async function hydrateDecisionComments(
|
|||||||
const typeById = new Map<number, IRequestType>();
|
const typeById = new Map<number, IRequestType>();
|
||||||
types.forEach((t) => typeById.set(t.id, t));
|
types.forEach((t) => typeById.set(t.id, t));
|
||||||
|
|
||||||
const rowsByType = new Map<number, IMyRequest[]>();
|
// По ПАРТИЦИИ, а не по типу: строки маршрута заявки лежат в списке
|
||||||
|
// согласования той же партиции, что и сама заявка. Для архивной заявки
|
||||||
|
// «список согласования её типа» — это чужой список текущей партиции, где по
|
||||||
|
// её TicketId нашлись бы либо ничего, либо комментарий к другой заявке.
|
||||||
|
const rowsByPartition = new Map<string, { target: IListTarget; typeName: string; rows: IMyRequest[] }>();
|
||||||
rows.forEach((r) => {
|
rows.forEach((r) => {
|
||||||
const list = rowsByType.get(r.requestTypeId) || [];
|
const key = r.listId.toLowerCase();
|
||||||
list.push(r);
|
const bucket = rowsByPartition.get(key) || {
|
||||||
rowsByType.set(r.requestTypeId, list);
|
target: { listId: r.listId, siteUrl: r.siteUrl, prefix: '' },
|
||||||
|
typeName: typeById.get(r.requestTypeId)?.name || String(r.requestTypeId),
|
||||||
|
rows: [],
|
||||||
|
};
|
||||||
|
bucket.rows.push(r);
|
||||||
|
rowsByPartition.set(key, bucket);
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Array.from(rowsByType.entries()).map(async ([requestTypeId, typeRows]) => {
|
Array.from(rowsByPartition.values()).map(async ({ target, typeName, rows: typeRows }) => {
|
||||||
const type = typeById.get(requestTypeId);
|
|
||||||
if (!type) return;
|
|
||||||
try {
|
try {
|
||||||
const approvalList = await approvalListFor(context, type);
|
const approvalList = await approvalListFor(context, target);
|
||||||
if (!approvalList) return;
|
if (!approvalList) return;
|
||||||
|
|
||||||
// TicketId (OR-цепочка) в скобках обязателен: у OData `and` связывает
|
// TicketId (OR-цепочка) в скобках обязателен: у OData `and` связывает
|
||||||
@@ -1010,7 +1199,7 @@ export async function hydrateDecisionComments(
|
|||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
`[Текущие заявки] «${type.name}»: не удалось прочитать комментарий к решению:`,
|
`[Текущие заявки] «${typeName}» (${target.listId}): не удалось прочитать комментарий к решению:`,
|
||||||
(e as Error).message
|
(e as Error).message
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1041,7 +1230,7 @@ export async function filterPendingByQuery(
|
|||||||
if (scopeTypes.length === 0) return [];
|
if (scopeTypes.length === 0) return [];
|
||||||
|
|
||||||
const sp = spForSite(scopeTypes[0].siteUrl as string, context);
|
const sp = spForSite(scopeTypes[0].siteUrl as string, context);
|
||||||
const pathClause = `(${scopeTypes.map((t) => `Path:"${listPathPrefix(t)}*"`).join(' OR ')})`;
|
const pathClause = pathClauseForTypes(scopeTypes);
|
||||||
|
|
||||||
const found = new Set<string>();
|
const found = new Set<string>();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -1060,12 +1249,12 @@ export async function filterPendingByQuery(
|
|||||||
);
|
);
|
||||||
// ListItemID уникален только внутри своего списка — сверяем пару тип+Id.
|
// ListItemID уникален только внутри своего списка — сверяем пару тип+Id.
|
||||||
hitsToEntries({ PrimarySearchResults: hits }, scopeTypes).forEach((e) =>
|
hitsToEntries({ PrimarySearchResults: hits }, scopeTypes).forEach((e) =>
|
||||||
found.add(`${e.type.id}-${e.id}`)
|
found.add(requestKey(e.target.listId, e.id))
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return rows.filter((r) => found.has(`${r.requestTypeId}-${r.id}`));
|
return rows.filter((r) => found.has(rowKey(r)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1099,7 +1288,7 @@ export async function searchOwnAll(
|
|||||||
const sp = spForSite(types[0].siteUrl as string, context);
|
const sp = spForSite(types[0].siteUrl as string, context);
|
||||||
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
|
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
|
||||||
const clauses = [
|
const clauses = [
|
||||||
`(${types.map((t) => `Path:"${listPathPrefix(t)}*"`).join(' OR ')})`,
|
pathClauseForTypes(types),
|
||||||
`AuthorOWSUSER:"${claimsLogin}"`,
|
`AuthorOWSUSER:"${claimsLogin}"`,
|
||||||
];
|
];
|
||||||
const text = freeTextClause(query);
|
const text = freeTextClause(query);
|
||||||
@@ -1114,7 +1303,7 @@ export async function searchOwnAll(
|
|||||||
|
|
||||||
const entries = hitsToEntries(results, types);
|
const entries = hitsToEntries(results, types);
|
||||||
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
|
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
|
||||||
const rows = entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r);
|
const rows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
|
||||||
|
|
||||||
traceResultRows('Свои заявки', types, entries, rows);
|
traceResultRows('Свои заявки', types, entries, rows);
|
||||||
return rows;
|
return rows;
|
||||||
@@ -1152,8 +1341,8 @@ export async function searchMyRequests(
|
|||||||
|
|
||||||
// Заявка может оказаться и своей, и involved одновременно (сам себе
|
// Заявка может оказаться и своей, и involved одновременно (сам себе
|
||||||
// согласующий) — оставляем как 'own', не дублируем строку.
|
// согласующий) — оставляем как 'own', не дублируем строку.
|
||||||
const ownKeys = new Set(ownRows.map((r) => `${r.requestTypeId}-${r.id}`));
|
const ownKeys = new Set(ownRows.map((r) => rowKey(r)));
|
||||||
const involvedOnly = involvedFiltered.filter((r) => !ownKeys.has(`${r.requestTypeId}-${r.id}`));
|
const involvedOnly = involvedFiltered.filter((r) => !ownKeys.has(rowKey(r)));
|
||||||
|
|
||||||
const merged = ownRows.concat(involvedOnly).sort((a, b) => b.created.localeCompare(a.created));
|
const merged = ownRows.concat(involvedOnly).sort((a, b) => b.created.localeCompare(a.created));
|
||||||
return { rows: merged, totalRows: merged.length };
|
return { rows: merged, totalRows: merged.length };
|
||||||
@@ -1163,7 +1352,8 @@ function toMyRequest(
|
|||||||
r: Record<string, unknown>,
|
r: Record<string, unknown>,
|
||||||
author: { EMail?: string; Title?: string } | undefined,
|
author: { EMail?: string; Title?: string } | undefined,
|
||||||
type: IRequestType,
|
type: IRequestType,
|
||||||
kind: 'own' | 'approval'
|
kind: 'own' | 'approval',
|
||||||
|
target: IListTarget
|
||||||
): IMyRequest {
|
): IMyRequest {
|
||||||
return {
|
return {
|
||||||
id: r.Id as number,
|
id: r.Id as number,
|
||||||
@@ -1177,5 +1367,7 @@ function toMyRequest(
|
|||||||
section: type.section,
|
section: type.section,
|
||||||
formKey: type.pageUrl,
|
formKey: type.pageUrl,
|
||||||
kind,
|
kind,
|
||||||
|
listId: target.listId,
|
||||||
|
siteUrl: target.siteUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,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,
|
||||||
|
|||||||
@@ -24,13 +24,25 @@ import {
|
|||||||
findUnassignedStep
|
findUnassignedStep
|
||||||
} 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);
|
||||||
@@ -40,7 +52,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);
|
||||||
@@ -52,7 +67,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);
|
||||||
@@ -69,7 +84,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]);
|
||||||
|
|
||||||
// Новая заявка — дата расторжения по умолчанию сегодняшняя (типичный случай:
|
// Новая заявка — дата расторжения по умолчанию сегодняшняя (типичный случай:
|
||||||
// обходной лист оформляют в день увольнения). При открытии на редактирование
|
// обходной лист оформляют в день увольнения). При открытии на редактирование
|
||||||
@@ -148,7 +163,14 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
|
|||||||
if (!rtId) {
|
if (!rtId) {
|
||||||
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||||
}
|
}
|
||||||
const { id, code } = await saveClearanceRequest(requestId, rtId, data, toApprovers(matrix.rows), status);
|
const { id, code } = await saveClearanceRequest(
|
||||||
|
requestId,
|
||||||
|
rtId,
|
||||||
|
data,
|
||||||
|
toApprovers(matrix.rows),
|
||||||
|
status,
|
||||||
|
editListId
|
||||||
|
);
|
||||||
setRequestId(id);
|
setRequestId(id);
|
||||||
setRequestStatus(status);
|
setRequestStatus(status);
|
||||||
if (code) {
|
if (code) {
|
||||||
@@ -158,11 +180,13 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
|
|||||||
onSaved();
|
onSaved();
|
||||||
}
|
}
|
||||||
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
||||||
|
// Имена списков — той же партиции, что и сама заявка (editListId), не текущей:
|
||||||
|
// поток ищет строку маршрута именно в НЕЙ.
|
||||||
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: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: approvalListTitle(FORM_ID)
|
approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -197,7 +221,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). decisionSeq в
|
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
|
||||||
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
||||||
@@ -66,7 +75,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);
|
||||||
@@ -85,7 +94,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [itemId]);
|
}, [itemId, listId]);
|
||||||
|
|
||||||
// Права на отметки — по должности ТЕКУЩЕГО активного шага пользователя.
|
// Права на отметки — по должности ТЕКУЩЕГО активного шага пользователя.
|
||||||
// isDelegator намеренно не даёт прав: делегировавший не решает за делегата
|
// isDelegator намеренно не даёт прав: делегировавший не решает за делегата
|
||||||
@@ -123,8 +132,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');
|
||||||
@@ -157,8 +166,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);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -187,7 +196,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,21 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
||||||
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
||||||
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
|
import { approvalListTitle, approvalListTitleForListId, 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));
|
*
|
||||||
|
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
|
||||||
|
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
|
||||||
|
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
|
||||||
|
* архивной партиции имя приходится резолвить через реестр — если реестр не
|
||||||
|
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
|
||||||
|
* а не тихо откатится на текущую партицию.
|
||||||
|
*/
|
||||||
|
export function clearanceApprovalService(listId?: string): ApprovalListService {
|
||||||
|
return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Отметки, которые проставляет не инициатор, а профильный согласующий (см. approverRights.ts). */
|
/** Отметки, которые проставляет не инициатор, а профильный согласующий (см. approverRights.ts). */
|
||||||
@@ -20,10 +29,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,7 +47,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 = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||||
|
await list.items.getById(id).update(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка). */
|
/** email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка). */
|
||||||
@@ -80,15 +94,22 @@ export interface ISaveResult {
|
|||||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||||
* Фазы исполнения у обходного листа нет — writeRoute вызывается с пустым списком исполнителей.
|
* Фазы исполнения у обходного листа нет — writeRoute вызывается с пустым списком исполнителей.
|
||||||
* Возвращает Id заявки (+ код при создании).
|
* Возвращает Id заявки (+ код при создании).
|
||||||
|
*
|
||||||
|
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadClearanceRequest). Имеет смысл
|
||||||
|
* только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции —
|
||||||
|
* писать новые заявки в архив незачем и нельзя (approval-route переключает
|
||||||
|
* текущую партицию сам, по своему порогу элементов).
|
||||||
*/
|
*/
|
||||||
export async function saveClearanceRequest(
|
export async function saveClearanceRequest(
|
||||||
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 list =
|
||||||
|
existingId && listId ? getSP().web.getList(listId) : 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);
|
||||||
|
|
||||||
@@ -105,6 +126,6 @@ export async function saveClearanceRequest(
|
|||||||
await list.items.getById(id).update({ RequestUniqueCode: code });
|
await list.items.getById(id).update({ RequestUniqueCode: code });
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeRoute(clearanceApprovalService(), id, approvers, [], status);
|
await writeRoute(clearanceApprovalService(existingId ? listId : undefined), id, approvers, [], status);
|
||||||
return { id, code };
|
return { id, code };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,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 { saveHireRequest, hireApprovalService } from './submit';
|
import { saveHireRequest, hireApprovalService } from './submit';
|
||||||
import { loadHireRequest } from './load';
|
import { loadHireRequest } from './load';
|
||||||
@@ -45,7 +50,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 +79,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 +96,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 +173,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);
|
||||||
};
|
};
|
||||||
@@ -177,7 +192,8 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
data,
|
data,
|
||||||
toApprovers(matrix.rows),
|
toApprovers(matrix.rows),
|
||||||
toApprovers(executors.rows),
|
toApprovers(executors.rows),
|
||||||
status
|
status,
|
||||||
|
editListId
|
||||||
);
|
);
|
||||||
setRequestId(id);
|
setRequestId(id);
|
||||||
setRequestStatus(status);
|
setRequestStatus(status);
|
||||||
@@ -185,19 +201,21 @@ 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(editListId || dataListTitle(FORM_ID), id, files); // файлы — после создания элемента
|
||||||
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
||||||
setExistingAttachments(await getAttachments(dataListTitle(FORM_ID), id)); // показать добавленные
|
setExistingAttachments(await getAttachments(editListId || dataListTitle(FORM_ID), id)); // показать добавленные
|
||||||
}
|
}
|
||||||
if (onSaved) {
|
if (onSaved) {
|
||||||
onSaved();
|
onSaved();
|
||||||
}
|
}
|
||||||
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
||||||
|
// Имена списков — той же партиции, что и сама заявка (editListId), не текущей:
|
||||||
|
// поток ищет строку маршрута именно в НЕЙ.
|
||||||
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: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: approvalListTitle(FORM_ID),
|
approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -237,7 +255,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,24 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
||||||
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
||||||
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
|
import { approvalListTitle, approvalListTitleForListId, 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));
|
*
|
||||||
|
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
|
||||||
|
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
|
||||||
|
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
|
||||||
|
* архивной партиции имя приходится резолвить через реестр — если реестр не
|
||||||
|
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
|
||||||
|
* а не тихо откатится на текущую партицию.
|
||||||
|
*/
|
||||||
|
export function hireApprovalService(listId?: string): ApprovalListService {
|
||||||
|
return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Осмысленный заголовок элемента списка (системная колонка Title). */
|
/** Осмысленный заголовок элемента списка (системная колонка Title). */
|
||||||
@@ -45,6 +54,11 @@ export interface ISaveResult {
|
|||||||
* status = «Черновик» (сохранение) или «На согласовании» (отправка).
|
* status = «Черновик» (сохранение) или «На согласовании» (отправка).
|
||||||
* executors может быть пустым — тогда после согласования заявка станет «Согласовано».
|
* executors может быть пустым — тогда после согласования заявка станет «Согласовано».
|
||||||
* Возвращает Id заявки (+ код при создании).
|
* Возвращает Id заявки (+ код при создании).
|
||||||
|
*
|
||||||
|
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadHireRequest). Имеет смысл
|
||||||
|
* только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции —
|
||||||
|
* писать новые заявки в архив незачем и нельзя (approval-route переключает
|
||||||
|
* текущую партицию сам, по своему порогу элементов).
|
||||||
*/
|
*/
|
||||||
export async function saveHireRequest(
|
export async function saveHireRequest(
|
||||||
existingId: number | undefined,
|
existingId: number | undefined,
|
||||||
@@ -52,9 +66,11 @@ 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 list =
|
||||||
|
existingId && listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||||
const item = buildItem(data, status);
|
const item = buildItem(data, status);
|
||||||
|
|
||||||
let id: number;
|
let id: number;
|
||||||
@@ -70,6 +86,6 @@ export async function saveHireRequest(
|
|||||||
await list.items.getById(id).update({ RequestUniqueCode: code });
|
await list.items.getById(id).update({ RequestUniqueCode: code });
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeRoute(hireApprovalService(), id, approvers, executors, status);
|
await writeRoute(hireApprovalService(existingId ? listId : undefined), id, approvers, executors, status);
|
||||||
return { id, code };
|
return { id, code };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,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 +50,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 +76,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 +94,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 +147,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 +160,35 @@ 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);
|
const { id, code } = await saveSelectionRequest(
|
||||||
|
requestId,
|
||||||
|
rtId,
|
||||||
|
data,
|
||||||
|
toApprovers(matrix.rows),
|
||||||
|
status,
|
||||||
|
editListId
|
||||||
|
);
|
||||||
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(editListId || dataListTitle(FORM_ID), id, files); // файлы — после создания элемента
|
||||||
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
||||||
setExistingAttachments(await getAttachments(dataListTitle(FORM_ID), id));
|
setExistingAttachments(await getAttachments(editListId || dataListTitle(FORM_ID), id));
|
||||||
}
|
}
|
||||||
if (onSaved) {
|
if (onSaved) {
|
||||||
onSaved();
|
onSaved();
|
||||||
}
|
}
|
||||||
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
||||||
|
// Имена списков — той же партиции, что и сама заявка (editListId), не текущей:
|
||||||
|
// поток ищет строку маршрута именно в НЕЙ.
|
||||||
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: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: approvalListTitle(FORM_ID)
|
approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -199,7 +223,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>
|
||||||
|
|||||||
@@ -17,8 +17,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;
|
||||||
@@ -28,8 +35,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(...scalarSelects, 'RequestStatus', 'RequestUniqueCode', ...personSelects)
|
.select(...scalarSelects, 'RequestStatus', 'RequestUniqueCode', ...personSelects)
|
||||||
.expand(...expands)()) as Record<string, unknown>;
|
.expand(...expands)()) as Record<string, unknown>;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
||||||
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
||||||
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
|
import { approvalListTitle, approvalListTitleForListId, 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 +11,18 @@ const MULTI_PERSON_FIELDS: (keyof IFormData)[] = ['HiringDecisionMaker'];
|
|||||||
// Числовые поля: пустая строка → null, иначе Number().
|
// Числовые поля: пустая строка → null, иначе Number().
|
||||||
const NUMBER_FIELDS: (keyof IFormData)[] = ['DepartmentHeadcount', 'RateProbation', 'RateAfterProbation'];
|
const NUMBER_FIELDS: (keyof IFormData)[] = ['DepartmentHeadcount', 'RateProbation', 'RateAfterProbation'];
|
||||||
|
|
||||||
/** Экземпляр сервиса списка согласования этой формы. */
|
/**
|
||||||
export function selectionApprovalService(): ApprovalListService {
|
* Экземпляр сервиса списка согласования этой формы.
|
||||||
return new ApprovalListService(approvalListTitle(FORM_ID));
|
*
|
||||||
|
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
|
||||||
|
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
|
||||||
|
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
|
||||||
|
* архивной партиции имя приходится резолвить через реестр — если реестр не
|
||||||
|
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
|
||||||
|
* а не тихо откатится на текущую партицию.
|
||||||
|
*/
|
||||||
|
export function selectionApprovalService(listId?: string): ApprovalListService {
|
||||||
|
return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Логин → числовой Id пользователя на текущем сайте. */
|
/** Логин → числовой Id пользователя на текущем сайте. */
|
||||||
@@ -68,15 +77,22 @@ export interface ISaveResult {
|
|||||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||||
* status = «Черновик» (сохранение) или «На согласовании» (отправка). Фазы исполнения
|
* status = «Черновик» (сохранение) или «На согласовании» (отправка). Фазы исполнения
|
||||||
* нет — writeRoute вызывается с пустым списком исполнителей. Возвращает Id (+ код при создании).
|
* нет — writeRoute вызывается с пустым списком исполнителей. Возвращает Id (+ код при создании).
|
||||||
|
*
|
||||||
|
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadSelectionRequest). Имеет смысл
|
||||||
|
* только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции —
|
||||||
|
* писать новые заявки в архив незачем и нельзя (approval-route переключает
|
||||||
|
* текущую партицию сам, по своему порогу элементов).
|
||||||
*/
|
*/
|
||||||
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 list =
|
||||||
|
existingId && listId ? getSP().web.getList(listId) : 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);
|
||||||
@@ -94,6 +110,6 @@ export async function saveSelectionRequest(
|
|||||||
await list.items.getById(id).update({ RequestUniqueCode: code });
|
await list.items.getById(id).update({ RequestUniqueCode: code });
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeRoute(selectionApprovalService(), id, approvers, [], status);
|
await writeRoute(selectionApprovalService(existingId ? listId : undefined), id, approvers, [], status);
|
||||||
return { id, code };
|
return { id, code };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,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 +68,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 +93,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 +111,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 +139,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 +203,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 +225,13 @@ 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);
|
const { id, code } = await saveTicketRequest(
|
||||||
|
requestId,
|
||||||
|
data,
|
||||||
|
toApprovers(matrix.rows),
|
||||||
|
finalStatus,
|
||||||
|
editListId
|
||||||
|
);
|
||||||
setRequestId(id);
|
setRequestId(id);
|
||||||
setRequestStatus(finalStatus);
|
setRequestStatus(finalStatus);
|
||||||
if (code) {
|
if (code) {
|
||||||
@@ -219,9 +240,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(editListId || dataListTitle(FORM_ID), id, files);
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
attachments = await getAttachments(dataListTitle(FORM_ID), id);
|
attachments = await getAttachments(editListId || dataListTitle(FORM_ID), id);
|
||||||
setExistingAttachments(attachments);
|
setExistingAttachments(attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,10 +251,12 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (finalStatus === REQUEST_STATUS.onApproval) {
|
if (finalStatus === REQUEST_STATUS.onApproval) {
|
||||||
|
// Имена списков — той же партиции, что и сама заявка (editListId), не
|
||||||
|
// текущей: поток ищет строку маршрута именно в НЕЙ.
|
||||||
await notifyActiveStep(context, approval, id, {
|
await notifyActiveStep(context, approval, id, {
|
||||||
requestTypeId: rtId,
|
requestTypeId: rtId,
|
||||||
dataListName: dataListTitle(FORM_ID),
|
dataListName: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: approvalListTitle(FORM_ID)
|
approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID)
|
||||||
});
|
});
|
||||||
} else if (finalStatus === REQUEST_STATUS.approved) {
|
} else if (finalStatus === REQUEST_STATUS.approved) {
|
||||||
// Согласование не требуется для этого вида работ — сразу передаём в отдел 1С.
|
// Согласование не требуется для этого вида работ — сразу передаём в отдел 1С.
|
||||||
@@ -245,7 +268,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, editListId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -284,7 +307,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,7 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
||||||
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
||||||
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
|
import { approvalListTitle, approvalListTitleForListId, dataListTitle } from '../../shared/listTitles';
|
||||||
import { FORM_ID, IFormData } from './model';
|
import { FORM_ID, IFormData } from './model';
|
||||||
|
|
||||||
// Префикс уникального кода заявки — буквенная метка «1С» вместо числового id
|
// Префикс уникального кода заявки — буквенная метка «1С» вместо числового id
|
||||||
@@ -9,9 +9,18 @@ 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));
|
*
|
||||||
|
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
|
||||||
|
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
|
||||||
|
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
|
||||||
|
* архивной партиции имя приходится резолвить через реестр — если реестр не
|
||||||
|
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
|
||||||
|
* а не тихо откатится на текущую партицию.
|
||||||
|
*/
|
||||||
|
export function ticket1cApprovalService(listId?: string): ApprovalListService {
|
||||||
|
return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Поля элемента списка данных из модели формы. TaskName пишется в системную колонку Title. */
|
/** Поля элемента списка данных из модели формы. TaskName пишется в системную колонку Title. */
|
||||||
@@ -38,14 +47,21 @@ export interface ISaveResult {
|
|||||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||||
* Фазы исполнения нет: после согласования заявка уходит в Planner, а не исполнителю
|
* Фазы исполнения нет: после согласования заявка уходит в Planner, а не исполнителю
|
||||||
* из матрицы. Возвращает Id заявки (+ код при создании).
|
* из матрицы. Возвращает Id заявки (+ код при создании).
|
||||||
|
*
|
||||||
|
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadTicketRequest). Имеет смысл
|
||||||
|
* только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции —
|
||||||
|
* писать новые заявки в архив незачем и нельзя (approval-route переключает
|
||||||
|
* текущую партицию сам, по своему порогу элементов).
|
||||||
*/
|
*/
|
||||||
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 list =
|
||||||
|
existingId && listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||||
const item = buildItem(data, status);
|
const item = buildItem(data, status);
|
||||||
|
|
||||||
let id: number;
|
let id: number;
|
||||||
@@ -61,7 +77,7 @@ export async function saveTicketRequest(
|
|||||||
await list.items.getById(id).update({ RequestUniqueCode: code });
|
await list.items.getById(id).update({ RequestUniqueCode: code });
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeRoute(ticket1cApprovalService(), id, approvers, [], status);
|
await writeRoute(ticket1cApprovalService(existingId ? listId : undefined), id, approvers, [], status);
|
||||||
return { id, code };
|
return { id, code };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,14 +86,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 = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||||
.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 = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||||
|
await list.items.getById(id).update({ PlannerTaskId: taskId });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,14 +36,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 +92,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 +109,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 +189,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);
|
||||||
};
|
};
|
||||||
@@ -193,7 +208,8 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({ requestType, context, on
|
|||||||
data,
|
data,
|
||||||
toApprovers(matrix.rows),
|
toApprovers(matrix.rows),
|
||||||
toApprovers(executors.rows),
|
toApprovers(executors.rows),
|
||||||
status
|
status,
|
||||||
|
editListId
|
||||||
);
|
);
|
||||||
setRequestId(id);
|
setRequestId(id);
|
||||||
setRequestStatus(status);
|
setRequestStatus(status);
|
||||||
@@ -201,18 +217,21 @@ 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(editListId || dataListTitle(FORM_ID), id, files);
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
setExistingAttachments(await getAttachments(dataListTitle(FORM_ID), id));
|
setExistingAttachments(await getAttachments(editListId || dataListTitle(FORM_ID), id));
|
||||||
}
|
}
|
||||||
if (onSaved) {
|
if (onSaved) {
|
||||||
onSaved();
|
onSaved();
|
||||||
}
|
}
|
||||||
|
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
||||||
|
// Имена списков — той же партиции, что и сама заявка (editListId), не текущей:
|
||||||
|
// поток ищет строку маршрута именно в НЕЙ.
|
||||||
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: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: approvalListTitle(FORM_ID)
|
approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -248,7 +267,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',
|
||||||
|
|||||||
@@ -2,12 +2,21 @@ import { getSP } from '../../shared/pnp/pnpjs';
|
|||||||
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
||||||
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
||||||
import { ILineItem } from '../../shared/ui';
|
import { ILineItem } from '../../shared/ui';
|
||||||
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
|
import { approvalListTitle, approvalListTitleForListId, 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));
|
*
|
||||||
|
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
|
||||||
|
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
|
||||||
|
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
|
||||||
|
* архивной партиции имя приходится резолвить через реестр — если реестр не
|
||||||
|
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
|
||||||
|
* а не тихо откатится на текущую партицию.
|
||||||
|
*/
|
||||||
|
export function tmcTransferApprovalService(listId?: string): ApprovalListService {
|
||||||
|
return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Осмысленный заголовок элемента (системная колонка Title). */
|
/** Осмысленный заголовок элемента (системная колонка Title). */
|
||||||
@@ -44,6 +53,11 @@ export interface ISaveResult {
|
|||||||
* Сохранить заявку «СЗ на перемещение ТМЦ»: контент + маршрут (согласующие + исполнители).
|
* Сохранить заявку «СЗ на перемещение ТМЦ»: контент + маршрут (согласующие + исполнители).
|
||||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||||
* Возвращает Id заявки (+ уникальный код при создании).
|
* Возвращает Id заявки (+ уникальный код при создании).
|
||||||
|
*
|
||||||
|
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadTmcTransferRequest). Имеет смысл
|
||||||
|
* только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции —
|
||||||
|
* писать новые заявки в архив незачем и нельзя (approval-route переключает
|
||||||
|
* текущую партицию сам, по своему порогу элементов).
|
||||||
*/
|
*/
|
||||||
export async function saveTmcTransferRequest(
|
export async function saveTmcTransferRequest(
|
||||||
existingId: number | undefined,
|
existingId: number | undefined,
|
||||||
@@ -51,9 +65,11 @@ 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 list =
|
||||||
|
existingId && listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||||
const item = buildItem(data, status);
|
const item = buildItem(data, status);
|
||||||
|
|
||||||
let id: number;
|
let id: number;
|
||||||
@@ -69,6 +85,6 @@ export async function saveTmcTransferRequest(
|
|||||||
await list.items.getById(id).update({ RequestUniqueCode: code });
|
await list.items.getById(id).update({ RequestUniqueCode: code });
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeRoute(tmcTransferApprovalService(), id, approvers, executors, status);
|
await writeRoute(tmcTransferApprovalService(existingId ? listId : undefined), id, approvers, executors, status);
|
||||||
return { id, code };
|
return { id, code };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,13 +24,25 @@ import {
|
|||||||
findUnassignedStep
|
findUnassignedStep
|
||||||
} 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 +52,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 +67,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 +84,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 +176,14 @@ 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);
|
const { id, code } = await saveUnpaidLeaveRequest(
|
||||||
|
requestId,
|
||||||
|
rtId,
|
||||||
|
data,
|
||||||
|
toApprovers(matrix.rows),
|
||||||
|
status,
|
||||||
|
editListId
|
||||||
|
);
|
||||||
setRequestId(id);
|
setRequestId(id);
|
||||||
setRequestStatus(status);
|
setRequestStatus(status);
|
||||||
if (code) {
|
if (code) {
|
||||||
@@ -171,11 +193,13 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({ requestType, context, on
|
|||||||
onSaved();
|
onSaved();
|
||||||
}
|
}
|
||||||
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
||||||
|
// Имена списков — той же партиции, что и сама заявка (editListId), не текущей:
|
||||||
|
// поток ищет строку маршрута именно в НЕЙ.
|
||||||
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: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: approvalListTitle(FORM_ID)
|
approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -210,7 +234,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,12 +1,21 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
||||||
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
||||||
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
|
import { approvalListTitle, approvalListTitleForListId, 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));
|
*
|
||||||
|
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
|
||||||
|
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
|
||||||
|
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
|
||||||
|
* архивной партиции имя приходится резолвить через реестр — если реестр не
|
||||||
|
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
|
||||||
|
* а не тихо откатится на текущую партицию.
|
||||||
|
*/
|
||||||
|
export function unpaidLeaveApprovalService(listId?: string): ApprovalListService {
|
||||||
|
return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка). */
|
/** email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка). */
|
||||||
@@ -66,15 +75,22 @@ export interface ISaveResult {
|
|||||||
* 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 list =
|
||||||
|
existingId && listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||||
const employee = await ensureUser(data.Employee);
|
const employee = await ensureUser(data.Employee);
|
||||||
// Замещающий необязателен: резолвим только если выбран.
|
// Замещающий необязателен: резолвим только если выбран.
|
||||||
const substituteId = data.Substitute ? (await ensureUser(data.Substitute)).Id : undefined;
|
const substituteId = data.Substitute ? (await ensureUser(data.Substitute)).Id : undefined;
|
||||||
@@ -93,6 +109,6 @@ export async function saveUnpaidLeaveRequest(
|
|||||||
await list.items.getById(id).update({ RequestUniqueCode: code });
|
await list.items.getById(id).update({ RequestUniqueCode: code });
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeRoute(unpaidLeaveApprovalService(), id, approvers, [], status);
|
await writeRoute(unpaidLeaveApprovalService(existingId ? listId : undefined), id, approvers, [], status);
|
||||||
return { id, code };
|
return { id, code };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,13 +24,25 @@ import {
|
|||||||
findUnassignedStep
|
findUnassignedStep
|
||||||
} 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 +52,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 +67,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 +84,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 +173,7 @@ 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);
|
const { id, code } = await saveVacationRequest(requestId, rtId, data, toApprovers(matrix.rows), status, editListId);
|
||||||
setRequestId(id);
|
setRequestId(id);
|
||||||
setRequestStatus(status);
|
setRequestStatus(status);
|
||||||
if (code) {
|
if (code) {
|
||||||
@@ -168,11 +183,13 @@ const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
|
|||||||
onSaved();
|
onSaved();
|
||||||
}
|
}
|
||||||
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
||||||
|
// Имена списков — той же партиции, что и сама заявка (editListId), не текущей:
|
||||||
|
// поток ищет строку маршрута именно в НЕЙ.
|
||||||
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: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: approvalListTitle(FORM_ID)
|
approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -207,7 +224,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,12 +1,21 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
||||||
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
||||||
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
|
import { approvalListTitle, approvalListTitleForListId, 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));
|
*
|
||||||
|
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
|
||||||
|
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
|
||||||
|
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
|
||||||
|
* архивной партиции имя приходится резолвить через реестр — если реестр не
|
||||||
|
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
|
||||||
|
* а не тихо откатится на текущую партицию.
|
||||||
|
*/
|
||||||
|
export function vacationApprovalService(listId?: string): ApprovalListService {
|
||||||
|
return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка). */
|
/** email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка). */
|
||||||
@@ -57,15 +66,22 @@ export interface ISaveResult {
|
|||||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||||
* Фазы исполнения у отпуска нет — writeRoute вызывается с пустым списком исполнителей.
|
* Фазы исполнения у отпуска нет — writeRoute вызывается с пустым списком исполнителей.
|
||||||
* Возвращает Id заявки (+ код при создании).
|
* Возвращает Id заявки (+ код при создании).
|
||||||
|
*
|
||||||
|
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadVacationRequest). Имеет смысл
|
||||||
|
* только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции —
|
||||||
|
* писать новые заявки в архив незачем и нельзя (approval-route переключает
|
||||||
|
* текущую партицию сам, по своему порогу элементов).
|
||||||
*/
|
*/
|
||||||
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 list =
|
||||||
|
existingId && listId ? getSP().web.getList(listId) : 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);
|
||||||
|
|
||||||
@@ -82,6 +98,6 @@ export async function saveVacationRequest(
|
|||||||
await list.items.getById(id).update({ RequestUniqueCode: code });
|
await list.items.getById(id).update({ RequestUniqueCode: code });
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeRoute(vacationApprovalService(), id, approvers, [], status);
|
await writeRoute(vacationApprovalService(existingId ? listId : undefined), id, approvers, [], status);
|
||||||
return { id, code };
|
return { id, code };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,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 { saveWorkplaceRequest, workplaceApprovalService } from './submit';
|
import { saveWorkplaceRequest, workplaceApprovalService } from './submit';
|
||||||
import { loadPrefillFromHire } from './prefill';
|
import { loadPrefillFromHire } from './prefill';
|
||||||
@@ -44,7 +49,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 +81,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 +98,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [editId]);
|
}, [editId, editListId]);
|
||||||
|
|
||||||
// Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource).
|
// Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -109,7 +122,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 +197,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);
|
||||||
};
|
};
|
||||||
@@ -201,7 +217,8 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
toApprovers(matrix.rows),
|
toApprovers(matrix.rows),
|
||||||
toApprovers(executors.rows),
|
toApprovers(executors.rows),
|
||||||
status,
|
status,
|
||||||
sourceId
|
sourceId,
|
||||||
|
editListId
|
||||||
);
|
);
|
||||||
setRequestId(id);
|
setRequestId(id);
|
||||||
setRequestStatus(status);
|
setRequestStatus(status);
|
||||||
@@ -209,19 +226,21 @@ 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(editListId || dataListTitle(FORM_ID), id, files);
|
||||||
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
||||||
setExistingAttachments(await getAttachments(dataListTitle(FORM_ID), id)); // показать добавленные
|
setExistingAttachments(await getAttachments(editListId || dataListTitle(FORM_ID), id)); // показать добавленные
|
||||||
}
|
}
|
||||||
if (onSaved) {
|
if (onSaved) {
|
||||||
onSaved();
|
onSaved();
|
||||||
}
|
}
|
||||||
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
||||||
|
// Имена списков — той же партиции, что и сама заявка (editListId), не текущей:
|
||||||
|
// поток ищет строку маршрута именно в НЕЙ.
|
||||||
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: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: approvalListTitle(FORM_ID),
|
approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -261,7 +280,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,37 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
||||||
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
||||||
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
|
import { approvalListTitle, approvalListTitleForListId, 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));
|
*
|
||||||
|
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
|
||||||
|
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
|
||||||
|
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
|
||||||
|
* архивной партиции имя приходится резолвить через реестр — если реестр не
|
||||||
|
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
|
||||||
|
* а не тихо откатится на текущую партицию.
|
||||||
|
*/
|
||||||
|
export function workplaceApprovalService(listId?: string): ApprovalListService {
|
||||||
|
return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обновить поля, которые заполняет согласующий-руководитель на этапе согласования
|
* Обновить поля, которые заполняет согласующий-руководитель на этапе согласования
|
||||||
* (инициатор часто не знает нужное ПО и доступы). Пишет только эти два поля.
|
* (инициатор часто не знает нужное ПО и доступы). Пишет только эти два поля.
|
||||||
|
*
|
||||||
|
* 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 = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||||
|
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,
|
||||||
});
|
});
|
||||||
@@ -59,6 +73,11 @@ export interface ISaveResult {
|
|||||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||||
* Исполнители пишутся сразу — фаза исполнения стартует автоматически после согласования.
|
* Исполнители пишутся сразу — фаза исполнения стартует автоматически после согласования.
|
||||||
* sourceHireId — если заявка создана на основе «прием». Возвращает Id заявки (+ код при создании).
|
* sourceHireId — если заявка создана на основе «прием». Возвращает Id заявки (+ код при создании).
|
||||||
|
*
|
||||||
|
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadWorkplaceRequest). Имеет смысл
|
||||||
|
* только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции —
|
||||||
|
* писать новые заявки в архив незачем и нельзя (approval-route переключает
|
||||||
|
* текущую партицию сам, по своему порогу элементов).
|
||||||
*/
|
*/
|
||||||
export async function saveWorkplaceRequest(
|
export async function saveWorkplaceRequest(
|
||||||
existingId: number | undefined,
|
existingId: number | undefined,
|
||||||
@@ -67,9 +86,11 @@ 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 list =
|
||||||
|
existingId && listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||||
const item = buildItem(data, status, sourceHireId);
|
const item = buildItem(data, status, sourceHireId);
|
||||||
|
|
||||||
let id: number;
|
let id: number;
|
||||||
@@ -85,6 +106,6 @@ export async function saveWorkplaceRequest(
|
|||||||
await list.items.getById(id).update({ RequestUniqueCode: code });
|
await list.items.getById(id).update({ RequestUniqueCode: code });
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeRoute(workplaceApprovalService(), id, approvers, executors, status);
|
await writeRoute(workplaceApprovalService(existingId ? listId : undefined), id, approvers, executors, status);
|
||||||
return { id, code };
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
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,
|
||||||
|
IApiResponse,
|
||||||
|
IRequestType,
|
||||||
|
IRequestTypeListSeries,
|
||||||
|
IResolvedRoute,
|
||||||
|
IResolveFieldValue
|
||||||
|
} from './models/IApprovalRouteApi';
|
||||||
|
|
||||||
// Все обращения к внешнему approval-route API — только здесь.
|
// Все обращения к внешнему approval-route API — только здесь.
|
||||||
// Клиент авторизуется через AadHttpClient (SSO по текущему пользователю).
|
// Клиент авторизуется через AadHttpClient (SSO по текущему пользователю).
|
||||||
@@ -14,7 +21,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 +31,29 @@ 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 ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
// Кеш на уровне МОДУЛЯ (не useRef/useState) — список типов заявок админ меняет
|
// Кеш на уровне МОДУЛЯ (не useRef/useState) — список типов заявок админ меняет
|
||||||
// редко, а без кеша каждая форма и каждый переход в режим согласующего дёргали
|
// редко, а без кеша каждая форма и каждый переход в режим согласующего дёргали
|
||||||
// бы /api/request-types заново. Живёт до перезагрузки страницы — тот же принцип,
|
// бы /api/request-types заново. Живёт до перезагрузки страницы — тот же принцип,
|
||||||
@@ -82,7 +112,7 @@ export async function resolveRoute(
|
|||||||
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 проекта) — для форм, где согласующий зависит от проекта
|
||||||
|
|||||||
@@ -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,13 +13,24 @@ 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,
|
||||||
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, resolveRoute } from './approvalRoute.service';
|
||||||
|
export {
|
||||||
|
initListPartitions,
|
||||||
|
listPartitionsLoaded,
|
||||||
|
partitionsForTypeId,
|
||||||
|
currentPartitionForTypeId,
|
||||||
|
currentPartitionForPageUrl,
|
||||||
|
partitionForPageUrlAndListId,
|
||||||
|
isConfirmedStaleListId,
|
||||||
|
} from './listPartitions';
|
||||||
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';
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { getListPartitions, getRequestTypes } from './approvalRoute.service';
|
||||||
|
import { 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конкретная партиция по её 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,36 @@ 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[];
|
||||||
|
}
|
||||||
|
|
||||||
// Условие видимости шага маршрута (настраивается в админке маршрутов —
|
// Условие видимости шага маршрута (настраивается в админке маршрутов —
|
||||||
// «Показывать шаг только при выполнении условия», сравнение «Поле списка»/
|
// «Показывать шаг только при выполнении условия», сравнение «Поле списка»/
|
||||||
// «Оператор»/«Значение»). fieldName — внутреннее имя колонки списка (напр.
|
// «Оператор»/«Значение»). fieldName — внутреннее имя колонки списка (напр.
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,26 @@ 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) — тот же приём, что и у debugUserEmail/
|
||||||
|
// setImpersonatedEmail: модульный синглтон, а не проброс параметром через все
|
||||||
|
// вызовы 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
|
||||||
|
|||||||
@@ -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} — согласование`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user