Compare commits
11 Commits
bc8a43ea35
...
80d8790ae1
| Author | SHA1 | Date | |
|---|---|---|---|
| 80d8790ae1 | |||
| b972879314 | |||
| bfe71f8c5a | |||
| fe03b9e640 | |||
| 960875dc38 | |||
| dc66cd1a06 | |||
| 3de50372a0 | |||
| 5b1a30fdf6 | |||
| 4fccfef7d5 | |||
| 3a71d0df1f | |||
| 5b2c062e81 |
@@ -295,6 +295,13 @@ module.exports = {
|
|||||||
'@microsoft/spfx/no-require-ensure': 2,
|
'@microsoft/spfx/no-require-ensure': 2,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Файлы локализации SPFx (loc/*.js) — это AMD-модули: их подключает
|
||||||
|
// загрузчик страницы через define(), а не бандлер. Без объявления среды
|
||||||
|
// no-undef считает define необъявленной переменной.
|
||||||
|
files: ['**/loc/*.js'],
|
||||||
|
env: { amd: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// For unit tests, we can be a little bit less strict. The settings below revise the
|
// For unit tests, we can be a little bit less strict. The settings below revise the
|
||||||
// defaults specified in the extended configurations, as well as above.
|
// defaults specified in the extended configurations, as well as above.
|
||||||
|
|||||||
@@ -3,27 +3,19 @@
|
|||||||
"id": "caca4ff9-050c-4643-936c-d0f29a0f9532",
|
"id": "caca4ff9-050c-4643-936c-d0f29a0f9532",
|
||||||
"alias": "RequestPortalWebPart",
|
"alias": "RequestPortalWebPart",
|
||||||
"componentType": "WebPart",
|
"componentType": "WebPart",
|
||||||
|
|
||||||
// The "*" signifies that the version should be taken from the package.json
|
|
||||||
"version": "*",
|
"version": "*",
|
||||||
"manifestVersion": 2,
|
"manifestVersion": 2,
|
||||||
|
|
||||||
// If true, the component can only be installed on sites where Custom Script is allowed.
|
|
||||||
// Components that allow authors to embed arbitrary script code should set this to true.
|
|
||||||
// https://support.office.com/en-us/article/Turn-scripting-capabilities-on-or-off-1f2c515f-5d7e-448a-9fd7-835da935584f
|
|
||||||
"requiresCustomScript": false,
|
"requiresCustomScript": false,
|
||||||
"supportedHosts": ["SharePointWebPart", "TeamsPersonalApp", "TeamsTab", "SharePointFullPage"],
|
"supportedHosts": ["SharePointWebPart", "TeamsPersonalApp", "TeamsTab", "SharePointFullPage"],
|
||||||
"supportsThemeVariants": true,
|
"supportsThemeVariants": true,
|
||||||
"supportsFullBleed": true,
|
"supportsFullBleed": true,
|
||||||
|
|
||||||
"preconfiguredEntries": [{
|
"preconfiguredEntries": [{
|
||||||
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Advanced
|
"groupId": "5c03119e-3074-46fd-976b-c60198311f70",
|
||||||
"group": { "default": "Advanced" },
|
"group": { "default": "Advanced" },
|
||||||
"title": { "default": "request-portal" },
|
"title": { "default": "request-portal" },
|
||||||
"description": { "default": "request-portal description" },
|
"description": { "default": "request-portal description" },
|
||||||
"officeFabricIconFontName": "Page",
|
"officeFabricIconFontName": "Page",
|
||||||
"properties": {
|
"properties": {}
|
||||||
"description": "request-portal"
|
|
||||||
}
|
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import * as ReactDom from 'react-dom';
|
import * as ReactDom from 'react-dom';
|
||||||
import { Version } from '@microsoft/sp-core-library';
|
import { Version } from '@microsoft/sp-core-library';
|
||||||
import {
|
import { type IPropertyPaneConfiguration, PropertyPaneDropdown } from '@microsoft/sp-property-pane';
|
||||||
type IPropertyPaneConfiguration,
|
|
||||||
PropertyPaneDropdown,
|
|
||||||
PropertyPaneTextField
|
|
||||||
} from '@microsoft/sp-property-pane';
|
|
||||||
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
|
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
|
||||||
import { IReadonlyTheme } from '@microsoft/sp-component-base';
|
import { IReadonlyTheme } from '@microsoft/sp-component-base';
|
||||||
|
|
||||||
@@ -14,15 +10,22 @@ import RequestPortal from './components/RequestPortal';
|
|||||||
import { IRequestPortalProps } from './components/IRequestPortalProps';
|
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 { setImpersonatedIdentity } from './shared/impersonation';
|
||||||
import { APPROVAL_API_BASE_URL_LOCAL, APPROVAL_API_BASE_URL_PROD, setApprovalApiBaseUrl } from './shared/config';
|
import { APPROVAL_API_BASE_URL_LOCAL, APPROVAL_API_BASE_URL_PROD, setApprovalApiBaseUrl } from './shared/config';
|
||||||
import { initListPartitions } from './shared/approval/listPartitions';
|
import { initListPartitions } from './shared/approval/listPartitions';
|
||||||
import { ErrorOverlay } from './shared/ui';
|
import { ErrorOverlay } from './shared/ui';
|
||||||
|
import { PropertyPaneDebugUser } from './propertyPane/PropertyPaneDebugUser';
|
||||||
|
|
||||||
export interface IRequestPortalWebPartProps {
|
export interface IRequestPortalWebPartProps {
|
||||||
description: string;
|
/**
|
||||||
/** Отладочная подмена личности — см. shared/impersonation.ts. На бою пусто. */
|
* Отладочная подмена личности — см. shared/impersonation.ts. На бою пусто.
|
||||||
debugUserEmail: string;
|
* Одно поле на оба случая: e-mail, введённый напрямую, хранится как есть;
|
||||||
|
* ФИО, найденное поиском по имени (см. propertyPane/DebugUserField), после
|
||||||
|
* резолва заменяется собой же на найденный e-mail — отдельное поле под
|
||||||
|
* «сырой» ввод не нужно, DebugUserField ведёт его в своём локальном состоянии.
|
||||||
|
*/
|
||||||
|
debugUserQuery: string;
|
||||||
|
debugUserDisplayName: string;
|
||||||
/** Куда шлём запросы approval-route (маршруты согласования) — прод или локальный backend. */
|
/** Куда шлём запросы approval-route (маршруты согласования) — прод или локальный backend. */
|
||||||
approvalApiBaseUrl: string;
|
approvalApiBaseUrl: string;
|
||||||
}
|
}
|
||||||
@@ -35,7 +38,7 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
|||||||
public render(): void {
|
public render(): void {
|
||||||
// Именно здесь, а не в onInit: свойство меняется в панели без перезапуска
|
// Именно здесь, а не в onInit: свойство меняется в панели без перезапуска
|
||||||
// веб-части, и render вызывается на каждое такое изменение.
|
// веб-части, и render вызывается на каждое такое изменение.
|
||||||
setImpersonatedEmail(this.properties.debugUserEmail);
|
setImpersonatedIdentity(this.properties.debugUserQuery, this.properties.debugUserDisplayName);
|
||||||
this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD;
|
this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD;
|
||||||
setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl);
|
setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl);
|
||||||
|
|
||||||
@@ -43,7 +46,11 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
|||||||
ErrorOverlay,
|
ErrorOverlay,
|
||||||
undefined,
|
undefined,
|
||||||
React.createElement<IRequestPortalProps>(RequestPortal, {
|
React.createElement<IRequestPortalProps>(RequestPortal, {
|
||||||
description: this.properties.description,
|
// key на подменённом e-mail: смена подмены в property pane должна
|
||||||
|
// пересобрать раздел заново — иначе счётчики в боковом меню и открытые
|
||||||
|
// таблицы остались бы от прежнего пользователя (их эффекты завязаны на
|
||||||
|
// монтирование/context, а не на identity подмены).
|
||||||
|
key: this.properties.debugUserQuery || '__self__',
|
||||||
isDarkTheme: this._isDarkTheme,
|
isDarkTheme: this._isDarkTheme,
|
||||||
environmentMessage: this._environmentMessage,
|
environmentMessage: this._environmentMessage,
|
||||||
hasTeamsContext: !!this.context.sdks.microsoftTeams,
|
hasTeamsContext: !!this.context.sdks.microsoftTeams,
|
||||||
@@ -80,7 +87,7 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
|||||||
if (!!this.context.sdks.microsoftTeams) { // running in Teams, office.com or Outlook
|
if (!!this.context.sdks.microsoftTeams) { // running in Teams, office.com or Outlook
|
||||||
return this.context.sdks.microsoftTeams.teamsJs.app.getContext()
|
return this.context.sdks.microsoftTeams.teamsJs.app.getContext()
|
||||||
.then(context => {
|
.then(context => {
|
||||||
let environmentMessage: string = '';
|
let environmentMessage: string;
|
||||||
switch (context.app.host.name) {
|
switch (context.app.host.name) {
|
||||||
case 'Office': // running in Office
|
case 'Office': // running in Office
|
||||||
environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentOffice : strings.AppOfficeEnvironment;
|
environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentOffice : strings.AppOfficeEnvironment;
|
||||||
@@ -140,13 +147,29 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
|||||||
{
|
{
|
||||||
groupName: strings.BasicGroupName,
|
groupName: strings.BasicGroupName,
|
||||||
groupFields: [
|
groupFields: [
|
||||||
PropertyPaneTextField('description', {
|
PropertyPaneDebugUser('debugUserQuery', {
|
||||||
label: strings.DescriptionFieldLabel
|
label: strings.DebugUserFieldLabel,
|
||||||
}),
|
description: strings.DebugUserFieldDescription,
|
||||||
PropertyPaneTextField('debugUserEmail', {
|
context: this.context,
|
||||||
label: strings.DebugUserEmailFieldLabel,
|
initialQuery: this.properties.debugUserQuery,
|
||||||
description: strings.DebugUserEmailFieldDescription,
|
onResolved: (query, email, displayName) => {
|
||||||
placeholder: 'employee@tnsu.kz'
|
// Сохраняем ТОЛЬКО e-mail (введённый напрямую или найденный по
|
||||||
|
// ФИО) — свойство одно и то же используется как e-mail для
|
||||||
|
// подмены (см. render() выше), так что писать в него сырое ФИО
|
||||||
|
// нельзя. Пустой query — поле очистили, подмену выключаем.
|
||||||
|
// А вот «email пуст, но query не пуст» — ФИО ещё не нашлось
|
||||||
|
// (недописано/не существует): ничего не трогаем, иначе неудачный
|
||||||
|
// промежуточный поиск молча выключил бы уже работающую подмену.
|
||||||
|
if (email) {
|
||||||
|
this.properties.debugUserQuery = email;
|
||||||
|
this.properties.debugUserDisplayName = displayName;
|
||||||
|
this.render();
|
||||||
|
} else if (!query) {
|
||||||
|
this.properties.debugUserQuery = '';
|
||||||
|
this.properties.debugUserDisplayName = '';
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
|
||||||
export interface IRequestPortalProps {
|
export interface IRequestPortalProps {
|
||||||
description: string;
|
|
||||||
isDarkTheme: boolean;
|
isDarkTheme: boolean;
|
||||||
environmentMessage: string;
|
environmentMessage: string;
|
||||||
hasTeamsContext: boolean;
|
hasTeamsContext: boolean;
|
||||||
|
|||||||
@@ -50,15 +50,49 @@
|
|||||||
// Раскладка: боковой рейл (side) на всю высоту + основная область (main).
|
// Раскладка: боковой рейл (side) на всю высоту + основная область (main).
|
||||||
.layout {
|
.layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch; // рейл тянется на высоту контента
|
align-items: flex-start; // рейл больше НЕ тянется по контенту — см. .side
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 78vh; // «присутствие» рейла даже когда контента мало
|
min-height: 78vh; // «присутствие» рейла даже когда контента мало
|
||||||
}
|
}
|
||||||
|
|
||||||
// Рейл-колонка: сам Sidebar тянется по высоте (flex-стретч).
|
// Рейл-колонка.
|
||||||
|
//
|
||||||
|
// Здесь сходятся два требования, которые по отдельности решаются по-разному:
|
||||||
|
//
|
||||||
|
// 1. Градиент рейла не должен менять оттенок от высоты соседней колонки. Раньше
|
||||||
|
// колонка тянулась по .main (align-items: stretch), и, например, доп. фильтр
|
||||||
|
// по статусу на вкладке «Инициированные мной» делал .main чуть выше — фон
|
||||||
|
// Sidebar (градиент от своей же высоты, см. $grad-header в @tnsu/ui-kit)
|
||||||
|
// заметно менял тон. Поэтому САМ РЕЙЛ прибит к высоте окна (100vh) и от
|
||||||
|
// содержимого рядом больше не зависит.
|
||||||
|
// 2. Под рейлом не должно быть белого провала на длинных таблицах. Прежняя
|
||||||
|
// версия задавала height:100vh самой КОЛОНКЕ — и всё, что ниже первого
|
||||||
|
// экрана, оставалось непрокрашенным: на длинном списке заявок рейл выглядел
|
||||||
|
// обрезанным. Ставку на sticky тут держать нельзя: в SharePoint прокручивается
|
||||||
|
// не окно, а внутренний контейнер страницы, и sticky там нередко не
|
||||||
|
// включается вовсе — тогда рейл просто уезжает вверх вместе с контентом.
|
||||||
|
//
|
||||||
|
// Отсюда разделение: КОЛОНКА тянется во всю высоту раскладки и закрашена
|
||||||
|
// $navy-800 — это цвет, которым градиент рейла заканчивается (на 85% и до
|
||||||
|
// конца), поэтому стык с липкой частью не виден. РЕЙЛ внутри — sticky на 100vh
|
||||||
|
// поверх этой заливки. Не включился sticky — под рейлом всё равно та же
|
||||||
|
// заливка, а не белизна.
|
||||||
|
//
|
||||||
|
// На мобильном брейкпоинте сам Sidebar уходит в position:fixed (см.
|
||||||
|
// @tnsu/ui-kit) — там эти правила уже не действуют, см. @media ниже.
|
||||||
.side {
|
.side {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-self: stretch;
|
||||||
|
background: $navy-800;
|
||||||
|
|
||||||
|
// Прямой потомок — корень Sidebar из @tnsu/ui-kit (своего класса снаружи не
|
||||||
|
// видно, поэтому селектор по структуре, а не по имени).
|
||||||
|
> * {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
@@ -98,6 +132,20 @@
|
|||||||
.mobileNav {
|
.mobileNav {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Снимаем десктопные правила рейла явно. Здесь ui-kit сам делает Sidebar
|
||||||
|
// position:fixed (top/bottom/left), и наш sticky+100vh спорил бы с ним при
|
||||||
|
// равной специфичности — исход решался бы порядком подключения стилей.
|
||||||
|
// Заливка колонки тоже не нужна: колонка схлопнута, drawer красит себя сам.
|
||||||
|
.side {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: none;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
position: static;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Полоса отладочной подмены личности (см. shared/impersonation.ts). Красная и
|
// Полоса отладочной подмены личности (см. shared/impersonation.ts). Красная и
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
OwnTab,
|
OwnTab,
|
||||||
} from '../shared/routing';
|
} from '../shared/routing';
|
||||||
import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents';
|
import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents';
|
||||||
import { getImpersonatedEmail } from '../shared/impersonation';
|
import { getImpersonatedEmail, getImpersonatedDisplayName } from '../shared/impersonation';
|
||||||
import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges';
|
import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges';
|
||||||
import { Drawer } from './Drawer/Drawer';
|
import { Drawer } from './Drawer/Drawer';
|
||||||
import { CategoryTabs } from './CategoryTabs/CategoryTabs';
|
import { CategoryTabs } from './CategoryTabs/CategoryTabs';
|
||||||
@@ -62,6 +62,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
// (setImpersonatedEmail в render) до создания этого элемента, и меняется оно
|
// (setImpersonatedEmail в render) до создания этого элемента, и меняется оно
|
||||||
// только вместе с новым render всей веб-части.
|
// только вместе с новым render всей веб-части.
|
||||||
const impersonatedEmail = getImpersonatedEmail();
|
const impersonatedEmail = getImpersonatedEmail();
|
||||||
|
const impersonatedDisplayName = getImpersonatedDisplayName();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const [navCollapsed, setNavCollapsed] = React.useState<boolean>(false);
|
const [navCollapsed, setNavCollapsed] = React.useState<boolean>(false);
|
||||||
@@ -133,7 +134,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
// загрузки в таблице не перезапустится сам. Шлём событие обновления —
|
// загрузки в таблице не перезапустится сам. Шлём событие обновления —
|
||||||
// таблица на него подписана и перечитает данные фоном, не убирая с экрана
|
// таблица на него подписана и перечитает данные фоном, не убирая с экрана
|
||||||
// текущие строки (см. RequestsTableView/SearchView).
|
// текущие строки (см. RequestsTableView/SearchView).
|
||||||
if (nav === route.nav && route.mode === 'submit') emitRequestsChanged();
|
if (nav === route.nav && route.mode === 'submit') emitRequestsChanged(true);
|
||||||
// Всё восстанавливаемое состояние раздела передаём ЯВНО, из запомненного.
|
// Всё восстанавливаемое состояние раздела передаём ЯВНО, из запомненного.
|
||||||
// parseRoute подставил бы его и сам, но тогда оно осталось бы только в
|
// parseRoute подставил бы его и сам, но тогда оно осталось бы только в
|
||||||
// состоянии и не попало бы в адрес: ссылка на раздел вела бы на первую
|
// состоянии и не попало бы в адрес: ссылка на раздел вела бы на первую
|
||||||
@@ -281,8 +282,11 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
закрыть: она исчезает только когда свойство веб-части очищено. */}
|
закрыть: она исчезает только когда свойство веб-части очищено. */}
|
||||||
{impersonatedEmail && (
|
{impersonatedEmail && (
|
||||||
<div className={styles.impersonationBar}>
|
<div className={styles.impersonationBar}>
|
||||||
Включена отладочная подмена: портал работает от имени <strong>{impersonatedEmail}</strong>.
|
Включена отладочная подмена: портал работает от имени{' '}
|
||||||
Очистите поле в свойствах веб-части, чтобы вернуться к своей учётной записи.
|
<strong>
|
||||||
|
{impersonatedDisplayName ? `${impersonatedDisplayName} (${impersonatedEmail})` : impersonatedEmail}
|
||||||
|
</strong>
|
||||||
|
. Очистите поле в свойствах веб-части, чтобы вернуться к своей учётной записи.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={styles.layout}>
|
<div className={styles.layout}>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { IMyRequest } from './models/IMyRequest';
|
|||||||
import { DataTable, IDataTableColumn } from '@tnsu/ui-kit';
|
import { DataTable, IDataTableColumn } from '@tnsu/ui-kit';
|
||||||
import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType, StatusBadge } from '../../shared/approval';
|
import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType, StatusBadge } from '../../shared/approval';
|
||||||
import { onRequestsChanged } from '../../shared/requestEvents';
|
import { onRequestsChanged } from '../../shared/requestEvents';
|
||||||
|
import { traced } from './perfTrace';
|
||||||
import { useRoutedQuery } from '../../shared/usePersistedQuery';
|
import { useRoutedQuery } from '../../shared/usePersistedQuery';
|
||||||
import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
|
import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||||||
import { Pager } from './Pager';
|
import { Pager } from './Pager';
|
||||||
@@ -208,9 +209,13 @@ const Table: React.FC<{
|
|||||||
// комментарий — по columnsFor (зависит от раздела и фильтра). Кастомные ячейки
|
// комментарий — по columnsFor (зависит от раздела и фильтра). Кастомные ячейки
|
||||||
// (статус → StatusBadge, дата → формат) задаются через render.
|
// (статус → StatusBadge, дата → формат) задаются через render.
|
||||||
const columns: IDataTableColumn<IMyRequest>[] = [
|
const columns: IDataTableColumn<IMyRequest>[] = [
|
||||||
{ key: 'id', title: 'ID', width: '70px' },
|
// Код заявки, а не Id элемента: Id уникален только внутри своей партиции
|
||||||
|
// (в каждом списке SharePoint нумерация с единицы), поэтому после архивации
|
||||||
|
// в таблице встречались разные заявки под одним номером. Кода нет у заявок,
|
||||||
|
// созданных до появления колонки — для них показываем Id, как раньше.
|
||||||
|
{ key: 'code', title: 'Код', width: '130px', render: (r) => r.code || `#${r.id}` },
|
||||||
{ key: 'type', title: 'Тип', width: '200px', render: (r) => r.requestTypeName },
|
{ key: 'type', title: 'Тип', width: '200px', render: (r) => r.requestTypeName },
|
||||||
{ key: 'title', title: 'Название', render: (r) => r.title || `#${r.id}` },
|
{ key: 'title', title: 'Название', render: (r) => r.title || r.code || `#${r.id}` },
|
||||||
{ key: 'date', title: 'Дата', width: '110px', render: (r) => formatDate(r.created) }
|
{ key: 'date', title: 'Дата', width: '110px', render: (r) => formatDate(r.created) }
|
||||||
];
|
];
|
||||||
if (cols.author) {
|
if (cols.author) {
|
||||||
@@ -392,16 +397,20 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const load = async (): Promise<void> => {
|
const load = async (forceRender = false): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const fresh = await fetchPage();
|
const fresh = await traced(`ИТОГО таблица:${view}#${page}`, fetchPage);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
// Перерисовываем, только если данные реально изменились — иначе при
|
// Перерисовываем, только если данные реально изменились — иначе при
|
||||||
// каждом возврате на вкладку таблица моргала бы, перерисовываясь тем же
|
// каждом возврате на вкладку таблица моргала бы, перерисовываясь тем же
|
||||||
// самым содержимым.
|
// самым содержимым. forceRender — исключение: строки уже спрятаны со
|
||||||
|
// экрана (см. force ниже), и их обязательно нужно вернуть, даже если
|
||||||
|
// свежий ответ побайтово совпал с тем, что было в кеше, — иначе таблица
|
||||||
|
// так и останется в состоянии «Загрузка…», хотя данные давно пришли
|
||||||
|
// (баг: ушёл на этом самом совпадении сигнатур, найден 2026-08-18).
|
||||||
const prev = readPage(cacheKey);
|
const prev = readPage(cacheKey);
|
||||||
if (!prev || pageSignature(prev) !== pageSignature(fresh)) {
|
if (forceRender || !prev || pageSignature(prev) !== pageSignature(fresh)) {
|
||||||
writePage(cacheKey, fresh);
|
writePage(cacheKey, fresh);
|
||||||
setRows(fresh.rows);
|
setRows(fresh.rows);
|
||||||
setTotalRows(fresh.totalRows);
|
setTotalRows(fresh.totalRows);
|
||||||
@@ -416,8 +425,15 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
load().catch(() => undefined); // ошибки уже обработаны внутри load через setError
|
load().catch(() => undefined); // ошибки уже обработаны внутри load через setError
|
||||||
const unsubscribe = onRequestsChanged(() => {
|
const unsubscribe = onRequestsChanged((force) => {
|
||||||
load().catch(() => undefined);
|
// Явный повторный клик по уже открытому пункту меню — пользователь ждёт
|
||||||
|
// видимого обновления, а не молчаливой подмены данных за спиной. Прежние
|
||||||
|
// строки прячем сразу; свежие подставит load(force).
|
||||||
|
if (force) {
|
||||||
|
setRows(undefined);
|
||||||
|
setLoadedKey('');
|
||||||
|
}
|
||||||
|
load(force).catch(() => undefined);
|
||||||
});
|
});
|
||||||
// Пока вкладка браузера скрыта, обновляться незачем — при возврате к ней
|
// Пока вкладка браузера скрыта, обновляться незачем — при возврате к ней
|
||||||
// сработает ближайший тик.
|
// сработает ближайший тик.
|
||||||
|
|||||||
@@ -126,7 +126,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.colId {
|
.colId {
|
||||||
width: 70px;
|
// Под код заявки («13-2608-42»), а не под Id элемента: прежних 70px на него
|
||||||
|
// не хватало (см. IMyRequest.code).
|
||||||
|
width: 130px;
|
||||||
color: $gray-400;
|
color: $gray-400;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getLinkedTypes, hydratePendingAuthors, searchMyRequests } from './myReq
|
|||||||
import { IMyRequest } from './models/IMyRequest';
|
import { IMyRequest } from './models/IMyRequest';
|
||||||
import { IRequestType, StatusBadge } from '../../shared/approval';
|
import { IRequestType, StatusBadge } from '../../shared/approval';
|
||||||
import { onRequestsChanged } from '../../shared/requestEvents';
|
import { onRequestsChanged } from '../../shared/requestEvents';
|
||||||
|
import { traced } from './perfTrace';
|
||||||
import { useRoutedQuery } from '../../shared/usePersistedQuery';
|
import { useRoutedQuery } from '../../shared/usePersistedQuery';
|
||||||
import { pageKey, pageSignature, readPage, writePage } from './pageCache';
|
import { pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||||||
import { Pager } from './Pager';
|
import { Pager } from './Pager';
|
||||||
@@ -66,7 +67,7 @@ const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => voi
|
|||||||
<table className={styles.table}>
|
<table className={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className={styles.colId}>ID</th>
|
<th className={styles.colId}>Код</th>
|
||||||
<th className={styles.colType}>Тип</th>
|
<th className={styles.colType}>Тип</th>
|
||||||
<th className={styles.colTitle}>Название</th>
|
<th className={styles.colTitle}>Название</th>
|
||||||
<th className={styles.colDate}>Дата</th>
|
<th className={styles.colDate}>Дата</th>
|
||||||
@@ -91,9 +92,10 @@ const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => voi
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td className={styles.colId}>{r.id}</td>
|
{/* Код заявки, а не Id элемента — см. IMyRequest.code и колонки в RequestsTableView. */}
|
||||||
|
<td className={styles.colId}>{r.code || `#${r.id}`}</td>
|
||||||
<td className={styles.colType}>{r.requestTypeName}</td>
|
<td className={styles.colType}>{r.requestTypeName}</td>
|
||||||
<td className={styles.colTitle}>{r.title || `#${r.id}`}</td>
|
<td className={styles.colTitle}>{r.title || r.code || `#${r.id}`}</td>
|
||||||
<td className={styles.colDate}>{formatDate(r.created)}</td>
|
<td className={styles.colDate}>{formatDate(r.created)}</td>
|
||||||
<td className={styles.colAuthor}>
|
<td className={styles.colAuthor}>
|
||||||
{[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'}
|
{[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'}
|
||||||
@@ -182,13 +184,16 @@ export const SearchView: React.FC<ISearchViewProps> = ({
|
|||||||
setRows(cached ? cached.rows : undefined);
|
setRows(cached ? cached.rows : undefined);
|
||||||
setTotalRows(cached ? cached.totalRows : 0);
|
setTotalRows(cached ? cached.totalRows : 0);
|
||||||
|
|
||||||
const load = async (): Promise<void> => {
|
const load = async (forceRender = false): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const fresh = await searchMyRequests(context, types, debouncedQuery);
|
const fresh = await traced('ИТОГО поиск', () => searchMyRequests(context, types, debouncedQuery));
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
applyKnownAuthors(fresh.rows, authors.current);
|
applyKnownAuthors(fresh.rows, authors.current);
|
||||||
const prev = readPage(cacheKey);
|
const prev = readPage(cacheKey);
|
||||||
if (!prev || pageSignature(prev) !== pageSignature(fresh)) {
|
// forceRender — строки уже спрятаны (см. force ниже) и обязаны
|
||||||
|
// вернуться, даже если свежий ответ совпал с прежним кешем: иначе
|
||||||
|
// экран так и останется в «Ищем…» (см. тот же баг в RequestsTableView).
|
||||||
|
if (forceRender || !prev || pageSignature(prev) !== pageSignature(fresh)) {
|
||||||
writePage(cacheKey, fresh);
|
writePage(cacheKey, fresh);
|
||||||
setRows(fresh.rows);
|
setRows(fresh.rows);
|
||||||
setTotalRows(fresh.totalRows);
|
setTotalRows(fresh.totalRows);
|
||||||
@@ -200,8 +205,9 @@ export const SearchView: React.FC<ISearchViewProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
load().catch(() => undefined);
|
load().catch(() => undefined);
|
||||||
const unsubscribe = onRequestsChanged(() => {
|
const unsubscribe = onRequestsChanged((force) => {
|
||||||
load().catch(() => undefined);
|
if (force) setRows(undefined);
|
||||||
|
load(force).catch(() => undefined);
|
||||||
});
|
});
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
if (!document.hidden) load().catch(() => undefined);
|
if (!document.hidden) load().catch(() => undefined);
|
||||||
|
|||||||
@@ -2,6 +2,18 @@
|
|||||||
// Собирается из списка данных SharePoint конкретного типа заявки (см. myRequests.service.ts).
|
// Собирается из списка данных SharePoint конкретного типа заявки (см. myRequests.service.ts).
|
||||||
export interface IMyRequest {
|
export interface IMyRequest {
|
||||||
id: number;
|
id: number;
|
||||||
|
/**
|
||||||
|
* Уникальный код заявки (колонка RequestUniqueCode) — вида «13-2608-42», см.
|
||||||
|
* shared/requestCode.ts. Именно он показывается пользователю вместо Id
|
||||||
|
* элемента: Id уникален только ВНУТРИ своей партиции (в каждом списке
|
||||||
|
* SharePoint нумерация с единицы), поэтому после архивации в одной таблице
|
||||||
|
* оказывались разные заявки с одинаковым номером.
|
||||||
|
*
|
||||||
|
* Пустая строка — заявка создана до появления колонки либо код не успел
|
||||||
|
* записаться (он проставляется вторым запросом, уже после создания элемента,
|
||||||
|
* см. saveRequestItem): тогда в таблице показывается Id, как раньше.
|
||||||
|
*/
|
||||||
|
code: string;
|
||||||
title: string;
|
title: string;
|
||||||
status: string;
|
status: string;
|
||||||
authorEmail: string;
|
authorEmail: string;
|
||||||
|
|||||||
@@ -6,12 +6,17 @@ import { IList } from '@pnp/sp/lists';
|
|||||||
import '@pnp/sp/items';
|
import '@pnp/sp/items';
|
||||||
import '@pnp/sp/site-users/web';
|
import '@pnp/sp/site-users/web';
|
||||||
import '@pnp/sp/search';
|
import '@pnp/sp/search';
|
||||||
|
// sp.batched() — довесок по свежести уходит одним запросом на сайт, см.
|
||||||
|
// fetchRecentApprovalRows. Импорт side-effect'ный: без него метод есть в типах,
|
||||||
|
// но в рантайме не зарегистрирован.
|
||||||
|
import '@pnp/sp/batching';
|
||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { getRequestTypes, IRequestType, partitionsForTypeId, 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';
|
||||||
import { traceLinkedTypes, traceResultRows, traceSearchHits } from './searchTrace';
|
import { traceLinkedTypes, traceResultRows, traceSearchHits } from './searchTrace';
|
||||||
|
import { traced } from './perfTrace';
|
||||||
|
|
||||||
// Список данных «... — данные» → список согласования «... — согласование»
|
// Список данных «... — данные» → список согласования «... — согласование»
|
||||||
// (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json).
|
// (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json).
|
||||||
@@ -19,7 +24,7 @@ const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
|
|||||||
|
|
||||||
// Свежие заявки/строки (созданы или изменены недавно) поисковый индекс может
|
// Свежие заявки/строки (созданы или изменены недавно) поисковый индекс может
|
||||||
// ещё не проиндексировать — такие добираем обычным REST-ом напрямую, в обход
|
// ещё не проиндексировать — такие добираем обычным REST-ом напрямую, в обход
|
||||||
// Search, и подмешиваем к результату (см. indexApprovalsForType/myTurn,
|
// Search, и подмешиваем к результату (см. buildPendingIndex/myTurn,
|
||||||
// getFreshOwnRowsForType). Используется ТОЛЬКО в паре с Search (гибрид для
|
// getFreshOwnRowsForType). Используется ТОЛЬКО в паре с Search (гибрид для
|
||||||
// таблиц без текстового запроса — там, где важны и масштаб, и мгновенная
|
// таблиц без текстового запроса — там, где важны и масштаб, и мгновенная
|
||||||
// свежесть); при поиске по тексту эта подстраховка не применяется (см. RequestsTableView).
|
// свежесть); при поиске по тексту эта подстраховка не применяется (см. RequestsTableView).
|
||||||
@@ -99,7 +104,7 @@ export type ApprovalScope = 'myTurn' | 'involved';
|
|||||||
// Очереди и истории, построенные на СТРОКАХ МАРШРУТА текущего пользователя.
|
// Очереди и истории, построенные на СТРОКАХ МАРШРУТА текущего пользователя.
|
||||||
// Отличаются только набором статусов его строки — сам механизм (найти свои
|
// Отличаются только набором статусов его строки — сам механизм (найти свои
|
||||||
// строки → взять их заявки) общий, поэтому статусы вынесены в параметр, а не
|
// строки → взять их заявки) общий, поэтому статусы вынесены в параметр, а не
|
||||||
// зашиты в indexApprovalsForType.
|
// зашиты в finalizeApprovalTarget.
|
||||||
//
|
//
|
||||||
// approvals/execution — очереди действий: ход за пользователем прямо сейчас.
|
// approvals/execution — очереди действий: ход за пользователем прямо сейчас.
|
||||||
// Раньше обе выдавались одним разделом «На согласовании» (фильтр включал
|
// Раньше обе выдавались одним разделом «На согласовании» (фильтр включал
|
||||||
@@ -152,6 +157,14 @@ const PENDING_PAGE_SIZE = 20;
|
|||||||
// (без поиска — просто нарезаем страницу), и режимом с текстовым поиском (там
|
// (без поиска — просто нарезаем страницу), и режимом с текстовым поиском (там
|
||||||
// нужен ПОЛНЫЙ набор, иначе фильтрация Search дала бы неверный итог/страницы —
|
// нужен ПОЛНЫЙ набор, иначе фильтрация Search дала бы неверный итог/страницы —
|
||||||
// см. filterPendingByQuery и вызывающую сторону в RequestsTableView).
|
// см. filterPendingByQuery и вызывающую сторону в RequestsTableView).
|
||||||
|
//
|
||||||
|
// ОДИН комбинированный Search по approval-листам ВСЕХ партиций ВСЕХ типов —
|
||||||
|
// раньше здесь было по отдельному Search-запросу НА КАЖДУЮ партицию (см.
|
||||||
|
// searchAcrossApprovalLists), и при архивации в несколько партиций это была
|
||||||
|
// самая дорогая часть загрузки «На согласовании» (трассировка 2026-08-18: 11
|
||||||
|
// партиций — 11 отдельных постраничных Search-запросов, каждый на 0.7-2с).
|
||||||
|
// Тот же приём OR-а по Path уже применялся для «Своих заявок» (см.
|
||||||
|
// pathClauseForTypes) — здесь он просто раскатан и на очередь согласования.
|
||||||
async function buildPendingIndex(
|
async function buildPendingIndex(
|
||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
scope: ApprovalScope,
|
scope: ApprovalScope,
|
||||||
@@ -160,16 +173,117 @@ async function buildPendingIndex(
|
|||||||
const types = await getLinkedTypes(context);
|
const types = await getLinkedTypes(context);
|
||||||
const tokenEmail = currentUserEmail(context);
|
const tokenEmail = currentUserEmail(context);
|
||||||
|
|
||||||
const perType = await Promise.all(
|
const debugByType = new Map<number, IMyRequestsTypeDebug>();
|
||||||
types.map((t) => indexApprovalsForType(context, t, tokenEmail, scope, statuses))
|
types.forEach((t) =>
|
||||||
|
debugByType.set(t.id, {
|
||||||
|
requestTypeId: t.id,
|
||||||
|
requestTypeName: t.name,
|
||||||
|
listId: t.listId,
|
||||||
|
siteUrl: t.siteUrl,
|
||||||
|
skipped: false,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Свежие сверху — типы объединяются в один список, сортировать нужно после слияния.
|
if (!tokenEmail) {
|
||||||
const index = perType
|
return { types, index: [], debug: types.map((t) => debugByType.get(t.id)!) };
|
||||||
.reduce((all, r) => all.concat(r.rows), [] as IMyRequest[])
|
}
|
||||||
.sort((a, b) => b.created.localeCompare(a.created));
|
|
||||||
|
|
||||||
return { types, index, debug: perType.map((r) => r.debug) };
|
// Партиции ВСЕХ типов — в один плоский список: комбинированный Search строит
|
||||||
|
// Path-условие по ним разом, поэтому дальше нет смысла обходить типы по
|
||||||
|
// отдельности.
|
||||||
|
const flat = types.reduce<Array<{ type: IRequestType; target: IListTarget }>>(
|
||||||
|
(all, t) => all.concat(listTargetsForType(t).map((target) => ({ type: t, target }))),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Резолв Title/адреса списка согласования остаётся ПОПАРТИЦИОННЫМ REST-ом:
|
||||||
|
// approval-route не хранит на него ссылку (список согласования ищется только
|
||||||
|
// по имени, см. shared/listTitles.ts), значит его АДРЕС узнать больше неоткуда.
|
||||||
|
// Но это лёгкий $select без постраничного перебора — не то же самое, что
|
||||||
|
// полноценный Search, который эти вызовы как раз заменяют.
|
||||||
|
const resolved = await Promise.all(
|
||||||
|
flat.map(({ type, target }) =>
|
||||||
|
traced(`resolveApprovalSearchTarget:${type.name}#${target.listId}`, () =>
|
||||||
|
resolveApprovalSearchTarget(context, type, target)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const okEntries: IApprovalSearchTarget[] = [];
|
||||||
|
resolved.forEach((r) => {
|
||||||
|
const debugType = r.kind === 'ok' ? r.value.type : r.type;
|
||||||
|
const debug = debugByType.get(debugType.id);
|
||||||
|
if (!debug) return;
|
||||||
|
if (r.kind === 'ok') {
|
||||||
|
okEntries.push(r.value);
|
||||||
|
} else if (r.kind === 'skip') {
|
||||||
|
if (!debug.approvalSkippedReason) debug.approvalSkippedReason = r.reason;
|
||||||
|
} else {
|
||||||
|
if (!debug.approvalError) debug.approvalError = r.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Два источника кандидатов — Search (весь архив) и REST-довесок по свежести
|
||||||
|
// (то, что индекс мог не успеть подхватить) — независимы друг от друга,
|
||||||
|
// поэтому идут параллельно, а не один за другим.
|
||||||
|
const [hitsByTarget, recentByTarget] = await Promise.all([
|
||||||
|
okEntries.length > 0
|
||||||
|
? traced('search:approvalsAcrossAll', () => searchAcrossApprovalLists(context, okEntries, tokenEmail, statuses))
|
||||||
|
: Promise.resolve(new Map<string, number[]>()),
|
||||||
|
// Довесок нужен только режиму «чья очередь сейчас»: при текстовом поиске
|
||||||
|
// важна полнота по архиву, а не мгновенная свежесть (см. scope='involved').
|
||||||
|
okEntries.length > 0 && scope === 'myTurn'
|
||||||
|
? traced('recentRowsAcrossAll', () => fetchRecentApprovalRows(context, okEntries, tokenEmail, statuses))
|
||||||
|
: Promise.resolve(new Map<string, Array<Record<string, unknown>>>()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const parts = await Promise.all(
|
||||||
|
okEntries.map((entry) =>
|
||||||
|
traced(`finalizeApprovalTarget:${entry.type.name}#${entry.target.listId}`, () =>
|
||||||
|
finalizeApprovalTarget(
|
||||||
|
context,
|
||||||
|
entry,
|
||||||
|
hitsByTarget.get(targetKey(entry)) ?? [],
|
||||||
|
recentByTarget.get(targetKey(entry)) ?? []
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const rowsByType = new Map<number, IMyRequest[]>();
|
||||||
|
const titlesByType = new Map<number, string[]>();
|
||||||
|
parts.forEach((part, i) => {
|
||||||
|
const entry = okEntries[i];
|
||||||
|
const debug = debugByType.get(entry.type.id);
|
||||||
|
if (!debug) return;
|
||||||
|
|
||||||
|
const bucket = rowsByType.get(entry.type.id) ?? [];
|
||||||
|
bucket.push(...part.rows);
|
||||||
|
rowsByType.set(entry.type.id, bucket);
|
||||||
|
|
||||||
|
if (part.approvalListTitle) {
|
||||||
|
const titles = titlesByType.get(entry.type.id) ?? [];
|
||||||
|
titles.push(part.approvalListTitle);
|
||||||
|
titlesByType.set(entry.type.id, titles);
|
||||||
|
}
|
||||||
|
if (part.error && !debug.approvalError) debug.approvalError = part.error;
|
||||||
|
debug.approvalRowCount = (debug.approvalRowCount ?? 0) + (part.approvalRowCount ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const index: IMyRequest[] = [];
|
||||||
|
types.forEach((t) => {
|
||||||
|
const debug = debugByType.get(t.id)!;
|
||||||
|
const titles = titlesByType.get(t.id);
|
||||||
|
if (titles && titles.length > 0) debug.approvalListTitle = titles.join(', ');
|
||||||
|
const typeRows = rowsByType.get(t.id) ?? [];
|
||||||
|
debug.approvalPendingTickets = typeRows.length;
|
||||||
|
index.push(...typeRows);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Свежие сверху — типы объединяются в один список, сортировать нужно после слияния.
|
||||||
|
index.sort((a, b) => b.created.localeCompare(a.created));
|
||||||
|
|
||||||
|
return { types, index, debug: types.map((t) => debugByType.get(t.id)!) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Без текстового поиска: страница нарезается ЗДЕСЬ (сервер решает, какие 20
|
// Без текстового поиска: страница нарезается ЗДЕСЬ (сервер решает, какие 20
|
||||||
@@ -223,17 +337,21 @@ export interface INavCounts {
|
|||||||
// «На согласовании» и «На исполнении» считаются за ОДИН проход: разделять их по
|
// «На согласовании» и «На исполнении» считаются за ОДИН проход: разделять их по
|
||||||
// отдельным запросам значило бы дважды просканировать одни и те же списки
|
// отдельным запросам значило бы дважды просканировать одни и те же списки
|
||||||
// согласования. Статус своей строки приходит в myStepStatus (см.
|
// согласования. Статус своей строки приходит в myStepStatus (см.
|
||||||
// indexApprovalsForType), по нему и разносим.
|
// finalizeApprovalTarget), по нему и разносим.
|
||||||
//
|
//
|
||||||
// «Доработка» живёт в других списках (данные, по автору), поэтому считается
|
// «Доработка» живёт в других списках (данные, по автору), поэтому считается
|
||||||
// своим запросом — но параллельно, и ровно тем же вызовом, что наполняет саму
|
// своим запросом — но параллельно, и ровно тем же вызовом, что наполняет саму
|
||||||
// вкладку: иначе счётчик и список могли бы разойтись.
|
// вкладку: иначе счётчик и список могли бы разойтись.
|
||||||
export async function getNavCounts(context: WebPartContext): Promise<INavCounts> {
|
export async function getNavCounts(context: WebPartContext): Promise<INavCounts> {
|
||||||
|
return traced('ИТОГО getNavCounts', () => getNavCountsInner(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getNavCountsInner(context: WebPartContext): Promise<INavCounts> {
|
||||||
const types = await getLinkedTypes(context);
|
const types = await getLinkedTypes(context);
|
||||||
|
|
||||||
const [turn, rework] = await Promise.all([
|
const [turn, rework] = await Promise.all([
|
||||||
buildPendingIndex(context, 'myTurn', MY_TURN_STATUSES),
|
traced('buildPendingIndex:myTurn', () => buildPendingIndex(context, 'myTurn', MY_TURN_STATUSES)),
|
||||||
getOwnRequestsByStatus(context, types, REQUEST_STATUS.returned, 1),
|
traced('getOwnRequestsByStatus:rework', () => getOwnRequestsByStatus(context, types, REQUEST_STATUS.returned, 1)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -259,138 +377,306 @@ 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(
|
|
||||||
context: WebPartContext,
|
|
||||||
type: IRequestType,
|
|
||||||
tokenEmail: string,
|
|
||||||
scope: ApprovalScope,
|
|
||||||
statuses: string[]
|
|
||||||
): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug }> {
|
|
||||||
const debug: IMyRequestsTypeDebug = {
|
|
||||||
requestTypeId: type.id,
|
|
||||||
requestTypeName: type.name,
|
|
||||||
listId: type.listId,
|
|
||||||
siteUrl: type.siteUrl,
|
|
||||||
skipped: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!tokenEmail) return { rows: [], debug };
|
/** Список согласования одной партиции, готовый к поиску: где искать и куда дочитывать. */
|
||||||
|
interface IApprovalSearchTarget {
|
||||||
// listTargetsForType сам откатывается на RequestType.listId, если реестр
|
type: IRequestType;
|
||||||
// партиций не загрузился — тогда ведём себя как раньше, по одному списку.
|
target: IListTarget;
|
||||||
const targets = listTargetsForType(type);
|
approvalList: IList;
|
||||||
|
approvalListTitle: string;
|
||||||
const rows: IMyRequest[] = [];
|
/** Абсолютный префикс пути элементов СПИСКА СОГЛАСОВАНИЯ, со слешем на конце. */
|
||||||
const approvalTitles: string[] = [];
|
approvalListAbsUrl: 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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Результат по одной партиции — строки плюс то, что нужно вкатить в общую диагностику типа. */
|
type ApprovalTargetResolution =
|
||||||
|
| { kind: 'ok'; value: IApprovalSearchTarget }
|
||||||
|
| { kind: 'skip'; type: IRequestType; target: IListTarget; reason: string }
|
||||||
|
| { kind: 'error'; type: IRequestType; target: IListTarget; message: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Резолвит список согласования одной партиции: Title (по конвенции из Title
|
||||||
|
* списка данных) и его абсолютный адрес пути.
|
||||||
|
*
|
||||||
|
* Title списка данных — из target.title (бесплатно, из bulk-ответа
|
||||||
|
* /api/request-types/lists, см. IListTarget.title), REST — только если реестр
|
||||||
|
* партиций был недоступен.
|
||||||
|
*
|
||||||
|
* Адрес списка согласования — ВСЕГДА REST-ом: approval-route не хранит на него
|
||||||
|
* ссылку (список согласования ищется только по имени, см. shared/listTitles.ts),
|
||||||
|
* сам список создаётся SharePoint под произвольным номером, и его адрес нельзя
|
||||||
|
* вывести из адреса списка данных. Но это лёгкий $select без постраничного
|
||||||
|
* перебора — не то же самое, что полноценный Search, который эти вызовы
|
||||||
|
* заменяют (см. searchAcrossApprovalLists).
|
||||||
|
*/
|
||||||
|
async function resolveApprovalSearchTarget(
|
||||||
|
context: WebPartContext,
|
||||||
|
type: IRequestType,
|
||||||
|
target: IListTarget
|
||||||
|
): Promise<ApprovalTargetResolution> {
|
||||||
|
try {
|
||||||
|
const sp = spForSite(target.siteUrl, context);
|
||||||
|
const list = sp.web.getList(target.listId);
|
||||||
|
|
||||||
|
const dataTitle =
|
||||||
|
target.title ??
|
||||||
|
((await traced(`dataListTitle:${type.name}#${target.listId}`, () => list.select('Title')())).Title as string) ??
|
||||||
|
'';
|
||||||
|
if (!DATA_LIST_SUFFIX.test(dataTitle)) {
|
||||||
|
return {
|
||||||
|
kind: 'skip',
|
||||||
|
type,
|
||||||
|
target,
|
||||||
|
reason: `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
|
||||||
|
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
|
||||||
|
|
||||||
|
const rootFolder = (await traced(`approvalListUrl:${type.name}#${target.listId}`, () =>
|
||||||
|
approvalList.select('RootFolder/ServerRelativeUrl').expand('RootFolder')()
|
||||||
|
)) as { RootFolder?: { ServerRelativeUrl?: string } };
|
||||||
|
const relUrl = rootFolder.RootFolder?.ServerRelativeUrl;
|
||||||
|
if (!relUrl) {
|
||||||
|
return { kind: 'error', type, target, message: `Список согласования «${approvalListTitle}» не найден` };
|
||||||
|
}
|
||||||
|
// Слеш на конце обязателен: без него префикс …/Lists/List1 совпадает и с
|
||||||
|
// путями элементов …/Lists/List10 (см. listPathPrefix).
|
||||||
|
const approvalListAbsUrl = `${siteOrigin(target.siteUrl)}${relUrl.replace(/\/+$/, '')}/`;
|
||||||
|
|
||||||
|
return { kind: 'ok', value: { type, target, approvalList, approvalListTitle, approvalListAbsUrl } };
|
||||||
|
} catch (e) {
|
||||||
|
return { kind: 'error', type, target, message: (e as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetKey(entry: IApprovalSearchTarget): string {
|
||||||
|
return entry.approvalListAbsUrl.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по согласующему — managed property MP_APPROVER. Имя нестандартное
|
||||||
|
// (с префиксом группы колонок сайта), см. комментарий к константе: «ожидаемого»
|
||||||
|
// ApproverOWSUSER в схеме поиска нет вовсе, из-за чего запросы по нему долго
|
||||||
|
// возвращали 0.
|
||||||
|
//
|
||||||
|
// statuses задаёт фильтр по статусу строки прямо в KQL — нужен для режима
|
||||||
|
// «чья сейчас очередь» (без текстового поиска), чтобы страницу резал СЕРВЕР:
|
||||||
|
// без него в выдачу попали бы все строки, где пользователь вообще есть в
|
||||||
|
// маршруте, и после отбрасывания неактивных в странице появлялись бы дыры.
|
||||||
|
// Пустой массив — не фильтровать (режим текстового поиска: там важна полнота).
|
||||||
|
//
|
||||||
|
// Search используется ТОЛЬКО чтобы сузить набор Id — TicketId и Status берёт
|
||||||
|
// СВЕЖИМИ через REST по этим Id вызывающая сторона (finalizeApprovalTarget), не
|
||||||
|
// из индекса (индекс мог устареть, а на устаревшем статусе решение «моя
|
||||||
|
// очередь» строить нельзя).
|
||||||
|
/**
|
||||||
|
* ОДИН комбинированный Search по спискам согласования ВСЕХ партиций ВСЕХ типов —
|
||||||
|
* Path через OR, approver и statuses общие для всех (см. buildPendingIndex, где
|
||||||
|
* это гарантировано одним вызовом на все типы разом). Тот же приём OR-а по Path
|
||||||
|
* уже применялся для «Своих заявок» (см. pathClauseForTypes) — раньше здесь
|
||||||
|
* вместо этого был отдельный Search-запрос НА КАЖДУЮ партицию, и при архивации
|
||||||
|
* в несколько партиций это было самой дорогой частью загрузки «На согласовании»
|
||||||
|
* (трассировка 2026-08-18: 11 партиций — 11 отдельных постраничных запросов).
|
||||||
|
*
|
||||||
|
* Ошибка одного общего запроса теперь означает «Search недоступен для всех», а
|
||||||
|
* не для одной партиции — компромисс сознательный: системный сбой Search и
|
||||||
|
* раньше задевал бы все партиции разом (это один и тот же сервис), а REST-net
|
||||||
|
* по свежести (recentRows, см. finalizeApprovalTarget) на сбой Search вообще не
|
||||||
|
* завязан и продолжает работать сам по себе.
|
||||||
|
*
|
||||||
|
* Возвращает найденные Id, сгруппированные по списку согласования (targetKey).
|
||||||
|
*/
|
||||||
|
async function searchAcrossApprovalLists(
|
||||||
|
context: WebPartContext,
|
||||||
|
targets: IApprovalSearchTarget[],
|
||||||
|
tokenEmail: string,
|
||||||
|
statuses: string[]
|
||||||
|
): Promise<Map<string, number[]>> {
|
||||||
|
const result = new Map<string, number[]>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`;
|
||||||
|
const pathClause = `(${targets.map((t) => `Path:"${t.approvalListAbsUrl}*"`).join(' OR ')})`;
|
||||||
|
const clauses = [pathClause, `${MP_APPROVER}:"${claimsLogin}"`];
|
||||||
|
if (statuses.length > 0) {
|
||||||
|
clauses.push(`(${statuses.map((s) => `${MP_STATUS}:"${s}"`).join(' OR ')})`);
|
||||||
|
}
|
||||||
|
const kql = clauses.join(' AND ');
|
||||||
|
|
||||||
|
// Представитель любого сайта годится — поисковый индекс общий на тенант
|
||||||
|
// (тот же приём, что в searchOwnRequests: spForSite(types[0].siteUrl, …)).
|
||||||
|
const sp = spForSite(targets[0].target.siteUrl, context);
|
||||||
|
const hits = await fetchAllSearchRows(sp, kql, ['ListItemID', 'Path'], 'На согласовании (все партиции)');
|
||||||
|
|
||||||
|
hits.forEach((h) => {
|
||||||
|
const id = Number(h.ListItemID);
|
||||||
|
if (isNaN(id) || id <= 0) return;
|
||||||
|
const path = String(h.Path || '').toLowerCase();
|
||||||
|
// Сравнение по границе сегмента (см. listPathPrefix) — тот же приём, что
|
||||||
|
// в hitsToEntries, но по адресам списков СОГЛАСОВАНИЯ, а не данных.
|
||||||
|
const matched = targets.filter((t) => path.indexOf(t.approvalListAbsUrl.toLowerCase()) === 0)[0];
|
||||||
|
if (!matched) return;
|
||||||
|
const key = targetKey(matched);
|
||||||
|
const arr = result.get(key) ?? [];
|
||||||
|
arr.push(id);
|
||||||
|
result.set(key, arr);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Текущие заявки] Комбинированный Search по спискам согласования не сработал:', (e as Error).message);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST-довесок по свежести для очереди согласования: строки маршрута, изменённые
|
||||||
|
* за последние RECENT_MS, — то, что поисковый индекс мог ещё не подхватить.
|
||||||
|
*
|
||||||
|
* Архивные партиции опрашиваются ТОЖЕ, и это не перестраховка: заявка, уехавшая
|
||||||
|
* в архив, продолжает висеть на согласовании, а решение по ней меняет строки
|
||||||
|
* маршрута ровно в её, архивной, партиции (см. approvalList.service.ts/setActive).
|
||||||
|
* Пропустить здесь архив — значит, что дошедшую до согласующего архивную заявку
|
||||||
|
* он увидит не сразу, а только когда её подхватит индекс; именно эту заявку
|
||||||
|
* фича «архивные заявки тоже согласуются» и должна показывать вовремя.
|
||||||
|
*
|
||||||
|
* Цена вопроса снята батчем, а не отказом от архива: все партиции одного сайта
|
||||||
|
* уходят ОДНИМ HTTP-запросом (batch API SharePoint — тот же приём, что в
|
||||||
|
* approvalList.service.ts/createSteps), поэтому число партиций больше не
|
||||||
|
* добавляет round-trip'ов. Раньше это был отдельный запрос на партицию, да ещё и
|
||||||
|
* с отдельным ensureUser перед каждым.
|
||||||
|
*/
|
||||||
|
async function fetchRecentApprovalRows(
|
||||||
|
context: WebPartContext,
|
||||||
|
entries: IApprovalSearchTarget[],
|
||||||
|
tokenEmail: string,
|
||||||
|
statuses: string[]
|
||||||
|
): Promise<Map<string, Array<Record<string, unknown>>>> {
|
||||||
|
const result = new Map<string, Array<Record<string, unknown>>>();
|
||||||
|
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
|
||||||
|
// Статус — прямо в $filter: REST-довесок сразу отдаёт только нужные строки,
|
||||||
|
// без постфильтрации в JS. Кириллица в значении этому не мешает — проверено
|
||||||
|
// вручную 2026-08-17, см. комментарий у MP_REQUEST_STATUS.
|
||||||
|
const statusFilter =
|
||||||
|
statuses.length > 0 ? ` and (${statuses.map((s) => `ApprovalStatus eq '${s}'`).join(' or ')})` : '';
|
||||||
|
|
||||||
|
// Группируем по САЙТУ: батч — это один запрос к одному сайту, и числовой Id
|
||||||
|
// пользователя тоже свой на каждом сайте.
|
||||||
|
const bySite = new Map<string, IApprovalSearchTarget[]>();
|
||||||
|
entries.forEach((e) => {
|
||||||
|
const key = e.target.siteUrl.toLowerCase();
|
||||||
|
const bucket = bySite.get(key) ?? [];
|
||||||
|
bucket.push(e);
|
||||||
|
bySite.set(key, bucket);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(bySite.values()).map(async (siteEntries) => {
|
||||||
|
const siteUrl = siteEntries[0].target.siteUrl;
|
||||||
|
try {
|
||||||
|
const sp = spForSite(siteUrl, context);
|
||||||
|
|
||||||
|
// ensureUser — ОДИН раз на сайт. Раньше он вызывался внутри обработки
|
||||||
|
// каждой партиции, то есть повторялся N раз с тем же результатом, и это
|
||||||
|
// POST, а не дешёвый GET.
|
||||||
|
//
|
||||||
|
// TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в
|
||||||
|
// SharePoint REST — поэтому резолвим email в числовой Id (как и везде в
|
||||||
|
// проекте) и фильтруем по TansuSiteApproverId.
|
||||||
|
const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`;
|
||||||
|
const approver = await traced(`ensureUser:${siteUrl}`, () => sp.web.ensureUser(claimsLogin));
|
||||||
|
const filter = `TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'${statusFilter}`;
|
||||||
|
|
||||||
|
const [batchedSP, execute] = sp.batched();
|
||||||
|
// Обработчики навешиваем СРАЗУ при создании промиса, до execute(): иначе
|
||||||
|
// отказ по одной партиции внутри батча всплыл бы как unhandled rejection
|
||||||
|
// раньше, чем мы успели бы его перехватить.
|
||||||
|
const pending = siteEntries.map((entry) =>
|
||||||
|
batchedSP.web.lists
|
||||||
|
.getByTitle(entry.approvalListTitle)
|
||||||
|
.items.filter(filter)
|
||||||
|
.select('Id', 'TicketId', 'ApprovalStatus')
|
||||||
|
.top(2000)()
|
||||||
|
.then((rows) => {
|
||||||
|
result.set(targetKey(entry), rows as Array<Record<string, unknown>>);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
// Недоступная партиция (архив переименовали/удалили вручную) не
|
||||||
|
// должна лишать довеска остальные партиции того же сайта.
|
||||||
|
console.error(
|
||||||
|
`[Текущие заявки] «${entry.type.name}» (${entry.target.listId}): недавно изменённые строки согласования не прочитаны:`,
|
||||||
|
(e as Error).message
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await traced(`recentRowsBatch:${siteUrl} (партиций: ${siteEntries.length})`, execute);
|
||||||
|
await Promise.all(pending);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Текущие заявки] Довесок по свежести для ${siteUrl} не сработал:`, (e as Error).message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Итог по одной партиции — строки плюс то, что нужно вкатить в общую диагностику типа. */
|
||||||
interface IPartitionApprovalResult {
|
interface IPartitionApprovalResult {
|
||||||
rows: IMyRequest[];
|
rows: IMyRequest[];
|
||||||
approvalListTitle?: string;
|
approvalListTitle?: string;
|
||||||
approvalSkippedReason?: string;
|
|
||||||
approvalRowCount?: number;
|
approvalRowCount?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function indexApprovalsForPartition(
|
/**
|
||||||
|
* Достраивает результат одной партиции поверх уже собранных кандидатов:
|
||||||
|
* • searchHitIds — Id из комбинированного Search (пусто, если для этой партиции
|
||||||
|
* ничего не нашлось или Search целиком не сработал);
|
||||||
|
* • recentRows — готовые строки из REST-довеска по свежести (пусто в режиме
|
||||||
|
* текстового поиска и когда за последние RECENT_MS ничего не менялось).
|
||||||
|
*
|
||||||
|
* Сама сеть здесь только на дочитывание: собственных «поисковых» запросов
|
||||||
|
* функция больше не делает — оба источника кандидатов собираются один раз на все
|
||||||
|
* партиции сразу (см. searchAcrossApprovalLists, fetchRecentApprovalRows).
|
||||||
|
*/
|
||||||
|
async function finalizeApprovalTarget(
|
||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
type: IRequestType,
|
entry: IApprovalSearchTarget,
|
||||||
target: IListTarget,
|
searchHitIds: number[],
|
||||||
tokenEmail: string,
|
recentRows: Array<Record<string, unknown>>
|
||||||
scope: ApprovalScope,
|
|
||||||
statuses: string[]
|
|
||||||
): Promise<IPartitionApprovalResult> {
|
): Promise<IPartitionApprovalResult> {
|
||||||
const debug: IPartitionApprovalResult = { rows: [] };
|
const { type, target, approvalList, approvalListTitle } = entry;
|
||||||
|
const debug: IPartitionApprovalResult = { rows: [], approvalListTitle };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sp = spForSite(target.siteUrl, context);
|
const sp = spForSite(target.siteUrl, context);
|
||||||
const list = sp.web.getList(target.listId);
|
const list = sp.web.getList(target.listId);
|
||||||
|
|
||||||
const dataListInfo = await list.select('Title')();
|
// TicketId/Status — СВЕЖИМИ через REST по найденным Id, не из индекса
|
||||||
const dataTitle = (dataListInfo.Title as string) || '';
|
// Search (см. doc-комментарий у searchAcrossApprovalLists).
|
||||||
if (!DATA_LIST_SUFFIX.test(dataTitle)) {
|
const searchRows =
|
||||||
debug.approvalSkippedReason = `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`;
|
searchHitIds.length === 0
|
||||||
return debug;
|
? []
|
||||||
}
|
: (
|
||||||
const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
|
await Promise.all(
|
||||||
debug.approvalListTitle = approvalListTitle;
|
// Id режем на пачки — тот же предел длины URL REST $filter, что и в
|
||||||
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
|
// loadByIds (см. REST_ID_FILTER_CHUNK).
|
||||||
|
chunk(searchHitIds, REST_ID_FILTER_CHUNK).map((idsChunk) =>
|
||||||
|
traced(`approvalRowsRest:${type.name}#${target.listId}`, () =>
|
||||||
|
approvalList.items
|
||||||
|
.filter(idsChunk.map((id) => `Id eq ${id}`).join(' or '))
|
||||||
|
.select('Id', 'TicketId', 'ApprovalStatus')
|
||||||
|
.top(idsChunk.length)()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).reduce((all, r) => all.concat(r as Array<Record<string, unknown>>), [] as Array<Record<string, unknown>>);
|
||||||
|
|
||||||
// TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в
|
// Довесок по свежести (если он был) идёт ПЕРВЫМ, а из Search берётся только
|
||||||
// SharePoint REST — резолвим email в числовой Id пользователя (как и везде в
|
// то, чего в нём ещё нет: у одной и той же строки статус в довеске свежее,
|
||||||
// проекте, см. ensureUser) и фильтруем по TansuSiteApproverId.
|
// чем в индексе, а решение «моя очередь сейчас» строится именно на статусе.
|
||||||
//
|
const seenIds = new Set(recentRows.map((r) => r.Id));
|
||||||
// ApprovalStatus в $filter теперь ИСПОЛЬЗУЕМ: раньше здесь считалось, что
|
const stepRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id)));
|
||||||
// кириллица в значении делает такой фильтр ненадёжным, — перепроверено
|
|
||||||
// вручную через REST 2026-08-17 (все три статуса списка согласования дали
|
|
||||||
// точное совпадение с эталоном), теория не подтвердилась. Старый вывод о
|
|
||||||
// поломке был артефактом кривого тестового запроса, а не реальным
|
|
||||||
// ограничением SharePoint.
|
|
||||||
const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`;
|
|
||||||
const approver = await sp.web.ensureUser(claimsLogin);
|
|
||||||
|
|
||||||
let stepRows: Array<Record<string, unknown>>;
|
|
||||||
|
|
||||||
if (scope === 'myTurn') {
|
|
||||||
// 'myTurn' — гибрид для масштаба (список согласования может быть очень
|
|
||||||
// большим): Search находит кандидатов, не сканируя список целиком, плюс
|
|
||||||
// REST по «последним N минутам» (Modified) добирает то, что Search мог ещё
|
|
||||||
// не проиндексировать. Status в обоих случаях берётся СВЕЖИМ через REST по
|
|
||||||
// найденным Id (не из индекса Search) — именно это делает
|
|
||||||
// getInvolvedRowsViaSearch, поэтому решение «моя очередь сейчас» всегда
|
|
||||||
// точное, даже когда список кандидатов пришёл из Search.
|
|
||||||
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
|
|
||||||
// Статус — прямо в $filter (см. комментарий про ApprovalStatus выше):
|
|
||||||
// REST-довесок сразу отдаёт только нужные строки, без постфильтрации в JS.
|
|
||||||
const statusFilter =
|
|
||||||
statuses.length > 0 ? ` and (${statuses.map((s) => `ApprovalStatus eq '${s}'`).join(' or ')})` : '';
|
|
||||||
const recentRows = (await approvalList.items
|
|
||||||
.filter(`TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'${statusFilter}`)
|
|
||||||
.select('Id', 'TicketId', 'ApprovalStatus')
|
|
||||||
.top(2000)()) as Array<Record<string, unknown>>;
|
|
||||||
|
|
||||||
// Статусы уходят и в KQL: сервер сразу отдаёт только нужные строки,
|
|
||||||
// а не всё, где пользователь есть в маршруте.
|
|
||||||
const searchRows = await getInvolvedRowsViaSearch(context, type, approvalList, statuses);
|
|
||||||
const seenIds = new Set(recentRows.map((r) => r.Id));
|
|
||||||
stepRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id)));
|
|
||||||
} else {
|
|
||||||
// 'involved' — используется ТОЛЬКО для текстового поиска (см.
|
|
||||||
// getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная
|
|
||||||
// свежесть, а полнота по всему архиву, поэтому только Search, без
|
|
||||||
// REST-подстраховки на «последние N минут»: гибрид REST+Search при поиске
|
|
||||||
// давал неустойчивый результат (заявка то находилась, то нет, в
|
|
||||||
// зависимости от того, успел ли Search её проиндексировать к моменту
|
|
||||||
// REST-окна).
|
|
||||||
stepRows = await getInvolvedRowsViaSearch(context, type, approvalList, statuses);
|
|
||||||
}
|
|
||||||
|
|
||||||
debug.approvalRowCount = stepRows.length;
|
debug.approvalRowCount = stepRows.length;
|
||||||
|
|
||||||
@@ -416,10 +702,12 @@ async function indexApprovalsForPartition(
|
|||||||
// Заголовок/статус/дата заявки — отдельным запросом к списку ДАННЫХ (см.
|
// Заголовок/статус/дата заявки — отдельным запросом к списку ДАННЫХ (см.
|
||||||
// комментарий у функции: через Lookup-expand это не отдаётся).
|
// комментарий у функции: через Lookup-expand это не отдаётся).
|
||||||
const ticketFilter = ticketIds.map((id) => `Id eq ${id}`).join(' or ');
|
const ticketFilter = ticketIds.map((id) => `Id eq ${id}`).join(' or ');
|
||||||
const ticketRows = (await list.items
|
const ticketRows = (await traced(`ticketRowsRest:${type.name}#${target.listId}`, () =>
|
||||||
.filter(ticketFilter)
|
list.items
|
||||||
.select('Id', 'Title', 'RequestStatus', 'Created')
|
.filter(ticketFilter)
|
||||||
.top(ticketIds.length)()) as Array<Record<string, unknown>>;
|
.select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created')
|
||||||
|
.top(ticketIds.length)()
|
||||||
|
)) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
debug.rows = ticketRows.map((r) => ({
|
debug.rows = ticketRows.map((r) => ({
|
||||||
...toMyRequest(r, undefined, type, 'approval', target),
|
...toMyRequest(r, undefined, type, 'approval', target),
|
||||||
@@ -437,83 +725,6 @@ async function indexApprovalsForPartition(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Строки согласования текущего пользователя через Search, чтобы не сканировать
|
|
||||||
// список целиком (он растёт с каждой заявкой).
|
|
||||||
//
|
|
||||||
// Фильтр по согласующему — managed property MP_APPROVER. Имя нестандартное
|
|
||||||
// (с префиксом группы колонок сайта), см. комментарий к константе: «ожидаемого»
|
|
||||||
// ApproverOWSUSER в схеме поиска нет вовсе, из-за чего запросы по нему долго
|
|
||||||
// возвращали 0.
|
|
||||||
//
|
|
||||||
// statuses задаёт фильтр по статусу строки прямо в KQL — нужен для режима
|
|
||||||
// «чья сейчас очередь» (без текстового поиска), чтобы страницу резал СЕРВЕР:
|
|
||||||
// без него в выдачу попали бы все строки, где пользователь вообще есть в
|
|
||||||
// маршруте, и после отбрасывания неактивных в странице появлялись бы дыры.
|
|
||||||
// Пустой массив — не фильтровать (режим текстового поиска: там важна полнота).
|
|
||||||
//
|
|
||||||
// Search используется ТОЛЬКО чтобы сузить набор Id — TicketId и Status берём
|
|
||||||
// свежими через REST по этим Id, а не из индекса (индекс мог устареть, а на
|
|
||||||
// устаревшем статусе решение «моя очередь» строить нельзя).
|
|
||||||
async function getInvolvedRowsViaSearch(
|
|
||||||
context: WebPartContext,
|
|
||||||
type: IRequestType,
|
|
||||||
approvalList: IList,
|
|
||||||
statuses: string[] = []
|
|
||||||
): Promise<Array<Record<string, unknown>>> {
|
|
||||||
try {
|
|
||||||
const email = currentUserEmail(context);
|
|
||||||
if (!email) {
|
|
||||||
console.warn(
|
|
||||||
`[Текущие заявки] «${type.name}»: у текущего пользователя нет email — Search по согласующему невозможен`
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const claimsLogin = `i:0#.f|membership|${email}`;
|
|
||||||
|
|
||||||
const rootFolder = (await approvalList
|
|
||||||
.select('RootFolder/ServerRelativeUrl')
|
|
||||||
.expand('RootFolder')()) as { RootFolder?: { ServerRelativeUrl?: string } };
|
|
||||||
const relUrl = rootFolder.RootFolder?.ServerRelativeUrl;
|
|
||||||
if (!relUrl) return [];
|
|
||||||
// Слеш на конце обязателен: без него префикс …/Lists/List1 совпадает и с
|
|
||||||
// путями элементов …/Lists/List10 (см. listPathPrefix).
|
|
||||||
const approvalListAbsUrl = `${siteOrigin(type.siteUrl as string)}${relUrl.replace(/\/+$/, '')}/`;
|
|
||||||
|
|
||||||
const sp = spForSite(type.siteUrl as string, context);
|
|
||||||
const pathClause = `Path:"${approvalListAbsUrl}*"`;
|
|
||||||
const clauses = [pathClause, `${MP_APPROVER}:"${claimsLogin}"`];
|
|
||||||
if (statuses.length > 0) {
|
|
||||||
clauses.push(`(${statuses.map((s) => `${MP_STATUS}:"${s}"`).join(' OR ')})`);
|
|
||||||
}
|
|
||||||
const kql = clauses.join(' AND ');
|
|
||||||
|
|
||||||
const hits = await fetchAllSearchRows(sp, kql, ['ListItemID'], `«${type.name}»`);
|
|
||||||
const ids = hits
|
|
||||||
.map((r) => Number(r.ListItemID))
|
|
||||||
.filter((id) => !isNaN(id) && id > 0);
|
|
||||||
|
|
||||||
if (ids.length === 0) return [];
|
|
||||||
|
|
||||||
// Id режем на пачки — тот же предел длины URL REST $filter, что и в
|
|
||||||
// loadByIds (см. REST_ID_FILTER_CHUNK).
|
|
||||||
const chunks = await Promise.all(
|
|
||||||
chunk(ids, REST_ID_FILTER_CHUNK).map((idsChunk) =>
|
|
||||||
approvalList.items
|
|
||||||
.filter(idsChunk.map((id) => `Id eq ${id}`).join(' or '))
|
|
||||||
.select('Id', 'TicketId', 'ApprovalStatus')
|
|
||||||
.top(idsChunk.length)()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return (chunks as Array<Array<Record<string, unknown>>>).reduce((all, r) => all.concat(r), []);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(
|
|
||||||
`[Текущие заявки] «${type.name}»: Search по списку согласования не сработал:`,
|
|
||||||
(e as Error).message
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Дочитывает Author/EMail + Author/Title в списке ДАННЫХ каждого типа —
|
// Дочитывает Author/EMail + Author/Title в списке ДАННЫХ каждого типа —
|
||||||
// только по переданным строкам (обычно уже нарезанная страница из ≤20), не по
|
// только по переданным строкам (обычно уже нарезанная страница из ≤20), не по
|
||||||
// всему найденному срезу. Мутирует переданные объекты (проставляет authorEmail/authorName).
|
// всему найденному срезу. Мутирует переданные объекты (проставляет authorEmail/authorName).
|
||||||
@@ -620,14 +831,16 @@ async function fetchAllSearchRows(
|
|||||||
let startRow = 0;
|
let startRow = 0;
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const results = await sp.search({
|
const results = await traced(`search:${label}#${startRow}`, () =>
|
||||||
Querytext: kql,
|
sp.search({
|
||||||
RowLimit: SEARCH_PAGE_SIZE,
|
Querytext: kql,
|
||||||
StartRow: startRow,
|
RowLimit: SEARCH_PAGE_SIZE,
|
||||||
SelectProperties: selectProperties,
|
StartRow: startRow,
|
||||||
TrimDuplicates: false,
|
SelectProperties: selectProperties,
|
||||||
...(sortList ? { SortList: sortList } : {}),
|
TrimDuplicates: false,
|
||||||
});
|
...(sortList ? { SortList: sortList } : {}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const page = results.PrimarySearchResults as Array<Record<string, unknown>>;
|
const page = results.PrimarySearchResults as Array<Record<string, unknown>>;
|
||||||
rows.push(...page);
|
rows.push(...page);
|
||||||
@@ -674,6 +887,14 @@ export interface IListTarget {
|
|||||||
siteUrl: string;
|
siteUrl: string;
|
||||||
/** Абсолютный префикс пути элементов этого списка, со слешем на конце. */
|
/** Абсолютный префикс пути элементов этого списка, со слешем на конце. */
|
||||||
prefix: string;
|
prefix: string;
|
||||||
|
/**
|
||||||
|
* Title списка данных на момент регистрации партиции (см. IListPartition.title) —
|
||||||
|
* уже пришёл бесплатно вместе с bulk-ответом /api/request-types/lists при
|
||||||
|
* старте страницы. Заполнен только когда партиция известна реестру; для
|
||||||
|
* фолбэка по RequestType.listId (реестр недоступен) — undefined, тогда
|
||||||
|
* resolveApprovalSearchTarget сам дочитает Title через REST.
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -698,7 +919,8 @@ export function listTargetsForType(type: IRequestType): IListTarget[] {
|
|||||||
return partitions.map((p) => ({
|
return partitions.map((p) => ({
|
||||||
listId: p.listId,
|
listId: p.listId,
|
||||||
siteUrl: p.siteUrl,
|
siteUrl: p.siteUrl,
|
||||||
prefix: `${siteOrigin(p.siteUrl).replace(/\/+$/, '')}${p.listId.replace(/\/+$/, '')}/`
|
prefix: `${siteOrigin(p.siteUrl).replace(/\/+$/, '')}${p.listId.replace(/\/+$/, '')}/`,
|
||||||
|
title: p.title
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -858,11 +1080,13 @@ async function loadByIds(
|
|||||||
|
|
||||||
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 traced(`loadByIds:${kind}:${type.name}#${target.listId}`, () =>
|
||||||
.filter(idsChunk.map((id) => `Id eq ${id}`).join(' or '))
|
list.items
|
||||||
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
.filter(idsChunk.map((id) => `Id eq ${id}`).join(' or '))
|
||||||
.expand('Author')
|
.select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
||||||
.top(idsChunk.length)()) as Array<Record<string, unknown>>;
|
.expand('Author')
|
||||||
|
.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(
|
byKey.set(
|
||||||
@@ -900,7 +1124,7 @@ async function getOwnRowsForType(context: WebPartContext, type: IRequestType): P
|
|||||||
const rows = (await sp.web
|
const rows = (await sp.web
|
||||||
.getList(target.listId)
|
.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', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
||||||
.expand('Author')
|
.expand('Author')
|
||||||
.orderBy('Created', false)
|
.orderBy('Created', false)
|
||||||
.top(OWN_REST_TOP)()) as Array<Record<string, unknown>>;
|
.top(OWN_REST_TOP)()) as Array<Record<string, unknown>>;
|
||||||
@@ -953,7 +1177,7 @@ async function getRecentlyChangedOwnRowsForType(context: WebPartContext, type: I
|
|||||||
const rows = (await sp.web
|
const rows = (await sp.web
|
||||||
.getList(target.listId)
|
.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', 'RequestUniqueCode', '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>>;
|
||||||
|
|
||||||
@@ -981,14 +1205,15 @@ export async function getOwnRequests(
|
|||||||
): Promise<IOwnRequestsPage> {
|
): Promise<IOwnRequestsPage> {
|
||||||
if (types.length === 0) return { rows: [], totalRows: 0 };
|
if (types.length === 0) return { rows: [], totalRows: 0 };
|
||||||
|
|
||||||
const searchPage = await searchOwnRequests(context, types, '', page);
|
const searchPage = await traced('searchOwnRequests', () => searchOwnRequests(context, types, '', page));
|
||||||
|
|
||||||
if (page !== 1) return searchPage;
|
if (page !== 1) return searchPage;
|
||||||
|
|
||||||
const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t)))).reduce(
|
const freshRows = (
|
||||||
(all, r) => all.concat(r),
|
await Promise.all(
|
||||||
[] as IMyRequest[]
|
types.map((t) => traced(`getFreshOwnRowsForType:${t.name}`, () => getFreshOwnRowsForType(context, t)))
|
||||||
);
|
)
|
||||||
|
).reduce((all, r) => all.concat(r), [] as IMyRequest[]);
|
||||||
|
|
||||||
const searchKeys = new Set(searchPage.rows.map((r) => rowKey(r)));
|
const searchKeys = new Set(searchPage.rows.map((r) => rowKey(r)));
|
||||||
const onlyFresh = freshRows.filter((r) => !searchKeys.has(rowKey(r)));
|
const onlyFresh = freshRows.filter((r) => !searchKeys.has(rowKey(r)));
|
||||||
@@ -1023,20 +1248,24 @@ export async function searchOwnRequests(
|
|||||||
if (text) clauses.push(text);
|
if (text) clauses.push(text);
|
||||||
const kql = clauses.join(' AND ');
|
const kql = clauses.join(' AND ');
|
||||||
|
|
||||||
const results = await sp.search({
|
const results = await traced('search:searchOwnRequests', () =>
|
||||||
Querytext: kql,
|
sp.search({
|
||||||
RowLimit: PAGE_SIZE,
|
Querytext: kql,
|
||||||
StartRow: (page - 1) * PAGE_SIZE,
|
RowLimit: PAGE_SIZE,
|
||||||
SelectProperties: ['ListItemID', 'Title', 'Path'],
|
StartRow: (page - 1) * PAGE_SIZE,
|
||||||
TrimDuplicates: false,
|
SelectProperties: ['ListItemID', 'Title', 'Path'],
|
||||||
// Свежие сверху (по умолчанию Search сортирует по релевантности, не по дате).
|
TrimDuplicates: false,
|
||||||
SortList: [{ Property: 'Created', Direction: 1 }],
|
// Свежие сверху (по умолчанию Search сортирует по релевантности, не по дате).
|
||||||
});
|
SortList: [{ Property: 'Created', Direction: 1 }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
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 traced('loadByIds:own(searchOwnRequests)', () => loadByIds(context, entries, 'own'))
|
||||||
|
: new Map<string, IMyRequest>();
|
||||||
// Порядок задаёт релевантность поиска, а не порядок ответов REST.
|
// Порядок задаёт релевантность поиска, а не порядок ответов REST.
|
||||||
const rows = entries.map((e) => byKey.get(requestKey(e.target.listId, 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);
|
||||||
|
|
||||||
@@ -1070,19 +1299,24 @@ export async function getOwnRequestsByStatus(
|
|||||||
if (text) clauses.push(text);
|
if (text) clauses.push(text);
|
||||||
const kql = clauses.join(' AND ');
|
const kql = clauses.join(' AND ');
|
||||||
|
|
||||||
const results = await sp.search({
|
const results = await traced('search:getOwnRequestsByStatus', () =>
|
||||||
Querytext: kql,
|
sp.search({
|
||||||
RowLimit: PAGE_SIZE,
|
Querytext: kql,
|
||||||
StartRow: (page - 1) * PAGE_SIZE,
|
RowLimit: PAGE_SIZE,
|
||||||
SelectProperties: ['ListItemID', 'Title', 'Path'],
|
StartRow: (page - 1) * PAGE_SIZE,
|
||||||
TrimDuplicates: false,
|
SelectProperties: ['ListItemID', 'Title', 'Path'],
|
||||||
// Свежие сверху — как в searchOwnRequests.
|
TrimDuplicates: false,
|
||||||
SortList: [{ Property: 'Created', Direction: 1 }],
|
// Свежие сверху — как в searchOwnRequests.
|
||||||
});
|
SortList: [{ Property: 'Created', Direction: 1 }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
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 traced('loadByIds:own(getOwnRequestsByStatus)', () => loadByIds(context, entries, 'own'))
|
||||||
|
: new Map<string, IMyRequest>();
|
||||||
const searchRows = entries.map((e) => byKey.get(requestKey(e.target.listId, 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 };
|
||||||
@@ -1090,7 +1324,13 @@ export async function getOwnRequestsByStatus(
|
|||||||
// Довесок по недавним изменениям — только на первой странице и только без
|
// Довесок по недавним изменениям — только на первой странице и только без
|
||||||
// текстового поиска (тот же принцип, что в getOwnRequests): заявку могли
|
// текстового поиска (тот же принцип, что в getOwnRequests): заявку могли
|
||||||
// вернуть на доработку только что, индекс мог не успеть её подхватить.
|
// вернуть на доработку только что, индекс мог не успеть её подхватить.
|
||||||
const freshRows = (await Promise.all(types.map((t) => getRecentlyChangedOwnRowsForType(context, t))))
|
const freshRows = (
|
||||||
|
await Promise.all(
|
||||||
|
types.map((t) =>
|
||||||
|
traced(`getRecentlyChangedOwnRowsForType:${t.name}`, () => getRecentlyChangedOwnRowsForType(context, t))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
.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);
|
||||||
|
|
||||||
@@ -1106,7 +1346,7 @@ export async function getOwnRequestsByStatus(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Список согласования по типу заявки: Title списка данных → Title списка
|
// Список согласования по типу заявки: Title списка данных → Title списка
|
||||||
// согласования по конвенции имён (та же логика, что в indexApprovalsForType).
|
// согласования по конвенции имён (та же логика, что в finalizeApprovalTarget).
|
||||||
// undefined — список заведён не по конвенции, маршрута для него не найти.
|
// undefined — список заведён не по конвенции, маршрута для него не найти.
|
||||||
async function approvalListFor(
|
async function approvalListFor(
|
||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
@@ -1357,6 +1597,7 @@ function toMyRequest(
|
|||||||
): IMyRequest {
|
): IMyRequest {
|
||||||
return {
|
return {
|
||||||
id: r.Id as number,
|
id: r.Id as number,
|
||||||
|
code: (r.RequestUniqueCode as string) || '',
|
||||||
title: (r.Title as string) || '',
|
title: (r.Title as string) || '',
|
||||||
status: (r.RequestStatus as string) || '',
|
status: (r.RequestStatus as string) || '',
|
||||||
authorEmail: author?.EMail || '',
|
authorEmail: author?.EMail || '',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache'
|
|||||||
function request(over: Partial<IMyRequest> = {}): IMyRequest {
|
function request(over: Partial<IMyRequest> = {}): IMyRequest {
|
||||||
return {
|
return {
|
||||||
id: 1,
|
id: 1,
|
||||||
|
code: '1-2608-1',
|
||||||
title: 'Заявка №1',
|
title: 'Заявка №1',
|
||||||
status: 'Черновик',
|
status: 'Черновик',
|
||||||
authorEmail: 'ivanov@tnsu.kz',
|
authorEmail: 'ivanov@tnsu.kz',
|
||||||
@@ -113,6 +114,15 @@ describe('pageSignature', () => {
|
|||||||
expect(pageSignature(before)).not.toBe(pageSignature(after));
|
expect(pageSignature(before)).not.toBe(pageSignature(after));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('появление кода заявки меняет подпись — он показан колонкой «Код»', () => {
|
||||||
|
// Код проставляется вторым запросом, уже после создания элемента (см.
|
||||||
|
// saveRequestItem), поэтому первая выдача приходит без него. Не учитывать
|
||||||
|
// его в подписи — значит оставить в таблице «#1» до перезагрузки страницы.
|
||||||
|
const before = page({ rows: [request({ code: '' })] });
|
||||||
|
const after = page({ rows: [request({ code: '1-2608-1' })] });
|
||||||
|
expect(pageSignature(before)).not.toBe(pageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
it('пустая страница имеет стабильную подпись', () => {
|
it('пустая страница имеет стабильную подпись', () => {
|
||||||
expect(pageSignature({ rows: [], totalRows: 0 })).toBe(pageSignature({ rows: [], totalRows: 0 }));
|
expect(pageSignature({ rows: [], totalRows: 0 })).toBe(pageSignature({ rows: [], totalRows: 0 }));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,10 +36,15 @@ export function writePage(key: string, page: IPage): void {
|
|||||||
// которые видны в таблице: если они совпали, перерисовывать нечего — иначе при
|
// которые видны в таблице: если они совпали, перерисовывать нечего — иначе при
|
||||||
// каждом возврате на вкладку и при каждом фоновом обновлении таблица моргала бы,
|
// каждом возврате на вкладку и при каждом фоновом обновлении таблица моргала бы,
|
||||||
// перерисовываясь тем же самым содержимым.
|
// перерисовываясь тем же самым содержимым.
|
||||||
|
// code — тоже видимое поле (колонка «Код»), и его обязательно учитывать: он
|
||||||
|
// проставляется ВТОРЫМ запросом, уже после создания элемента (см.
|
||||||
|
// saveRequestItem), поэтому первая выдача может прийти без него. Без code в
|
||||||
|
// слепке подъехавший позже код совпал бы с прежней подписью, перерисовки не
|
||||||
|
// случилось бы, и в таблице до перезагрузки страницы висело бы «#5».
|
||||||
export function pageSignature(page: IPage): string {
|
export function pageSignature(page: IPage): string {
|
||||||
return `${page.totalRows}|${page.rows
|
return `${page.totalRows}|${page.rows
|
||||||
.map((r) =>
|
.map((r) =>
|
||||||
[r.requestTypeId, r.id, r.title, r.status, r.created, r.authorName, r.decisionComment ?? ''].join('~')
|
[r.requestTypeId, r.id, r.code, r.title, r.status, r.created, r.authorName, r.decisionComment ?? ''].join('~')
|
||||||
)
|
)
|
||||||
.join(';')}`;
|
.join(';')}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Замер времени отдельных этапов загрузки «Моих заявок» — что именно тормозит
|
||||||
|
* (индекс согласований, свои заявки по типу, конкретная партиция, Search),
|
||||||
|
* а не просто «страница долго грузится».
|
||||||
|
*
|
||||||
|
* ВРЕМЕННО: пишет всегда, без флага-переключателя — этот модуль и все traced()
|
||||||
|
* вокруг снимаются целиком, как только разбор текущей проблемы с
|
||||||
|
* производительностью закончится (см. myRequests.service.ts, RequestsTableView.tsx,
|
||||||
|
* SearchView.tsx).
|
||||||
|
*/
|
||||||
|
const PREFIX = '[Перф]';
|
||||||
|
|
||||||
|
/** Оборачивает async-шаг замером времени. */
|
||||||
|
export async function traced<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
const start = performance.now();
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
console.log(`${PREFIX} ${label}: ${(performance.now() - start).toFixed(0)} мс`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,8 +14,12 @@ import { IRequestType } from '../../shared/approval';
|
|||||||
* window.rpSearchTrace = true // следить за всеми типами
|
* window.rpSearchTrace = true // следить за всеми типами
|
||||||
* window.rpSearchTrace = false // выключить
|
* window.rpSearchTrace = false // выключить
|
||||||
* Значение сверяется с именем типа и с pageUrl (ключом формы), регистр не важен.
|
* Значение сверяется с именем типа и с pageUrl (ключом формы), регистр не важен.
|
||||||
|
*
|
||||||
|
* По умолчанию выключена (false) — включённый DEFAULT_TRACE был точечной
|
||||||
|
* настройкой на время разбора конкретного бага с партициями «Обходного листа»
|
||||||
|
* и не должен постоянно писать в консоль всем остальным.
|
||||||
*/
|
*/
|
||||||
const DEFAULT_TRACE: string | boolean = 'обходной';
|
const DEFAULT_TRACE: string | boolean = false;
|
||||||
|
|
||||||
const PREFIX = '[Поиск]';
|
const PREFIX = '[Поиск]';
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ import {
|
|||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
|
resolveWriteListId,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
IApproverToWrite,
|
IApproverToWrite,
|
||||||
|
APPROVAL_PHASE,
|
||||||
findUnassignedStep
|
findUnassignedStep
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
@@ -121,17 +123,42 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
|
|||||||
const loadMatrix = useCallback(
|
const loadMatrix = useCallback(
|
||||||
() =>
|
() =>
|
||||||
editId
|
editId
|
||||||
? approval.getMatrixForRequest(editId)
|
? approval
|
||||||
|
.getMatrixForRequest(editId)
|
||||||
|
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution))
|
||||||
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval', null, [
|
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval', null, [
|
||||||
{ fieldName: 'Position', value: data.Position }
|
{ fieldName: 'Position', value: data.Position }
|
||||||
]),
|
]),
|
||||||
[context, approval, editId, data.Employee, data.Position, requestTypeId]
|
[context, approval, editId, data.Employee, data.Position, requestTypeId]
|
||||||
);
|
);
|
||||||
|
const matrixReady = !!editId || (!!data.Employee && !!requestTypeId);
|
||||||
const matrix = useMatrixEditor(
|
const matrix = useMatrixEditor(
|
||||||
loadMatrix,
|
loadMatrix,
|
||||||
`${data.Employee}-${editId ?? 'new'}-${data.Position}`,
|
`${data.Employee}-${editId ?? 'new'}-${data.Position}`,
|
||||||
!readOnly,
|
!readOnly,
|
||||||
!!editId || (!!data.Employee && !!requestTypeId),
|
matrixReady,
|
||||||
|
(login) => approval.getJobTitle(login)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Фаза исполнения: тот же маршрут (условие «IsManager» по data.Position — см.
|
||||||
|
// комментарий у loadMatrix выше), только 'Execution' вместо 'Approval'. У
|
||||||
|
// открытой на редактирование заявки — строки уже записаны, читаем как есть.
|
||||||
|
const loadExecutors = useCallback(
|
||||||
|
() =>
|
||||||
|
editId
|
||||||
|
? approval
|
||||||
|
.getMatrixForRequest(editId)
|
||||||
|
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution))
|
||||||
|
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Execution', null, [
|
||||||
|
{ fieldName: 'Position', value: data.Position }
|
||||||
|
]),
|
||||||
|
[context, approval, editId, data.Employee, data.Position, requestTypeId]
|
||||||
|
);
|
||||||
|
const executors = useMatrixEditor(
|
||||||
|
loadExecutors,
|
||||||
|
`e-${data.Employee}-${editId ?? 'new'}-${data.Position}`,
|
||||||
|
!readOnly,
|
||||||
|
matrixReady,
|
||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -163,13 +190,22 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
|
|||||||
if (!rtId) {
|
if (!rtId) {
|
||||||
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||||
}
|
}
|
||||||
|
// requestId уже задан → редактируем существующую заявку, пишем в ту же
|
||||||
|
// партицию (editListId). Иначе — создаём новую, и партицию для записи
|
||||||
|
// спрашиваем у approval-route ПРЯМО СЕЙЧАС (а не берём из кеша onInit):
|
||||||
|
// это единственный запрос, который проверяет порог элементов текущего
|
||||||
|
// списка и ставит в очередь создание следующей партиции при достижении —
|
||||||
|
// без него архивация никогда не сработает. Сбой запроса не должен
|
||||||
|
// блокировать подачу заявки — откатываемся на список по конвенции имён.
|
||||||
|
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
||||||
const { id, code } = await saveClearanceRequest(
|
const { id, code } = await saveClearanceRequest(
|
||||||
requestId,
|
requestId,
|
||||||
rtId,
|
rtId,
|
||||||
data,
|
data,
|
||||||
toApprovers(matrix.rows),
|
toApprovers(matrix.rows),
|
||||||
|
toApprovers(executors.rows),
|
||||||
status,
|
status,
|
||||||
editListId
|
writeListId
|
||||||
);
|
);
|
||||||
setRequestId(id);
|
setRequestId(id);
|
||||||
setRequestStatus(status);
|
setRequestStatus(status);
|
||||||
@@ -180,13 +216,14 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
|
|||||||
onSaved();
|
onSaved();
|
||||||
}
|
}
|
||||||
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
||||||
// Имена списков — той же партиции, что и сама заявка (editListId), не текущей:
|
// Имена списков — той же партиции, куда фактически записали (writeListId),
|
||||||
// поток ищет строку маршрута именно в НЕЙ.
|
// не обязательно текущей на момент рендера формы: поток ищет строку
|
||||||
|
// маршрута именно в НЕЙ.
|
||||||
if (status === REQUEST_STATUS.onApproval) {
|
if (status === REQUEST_STATUS.onApproval) {
|
||||||
await notifyActiveStep(context, approval, id, {
|
await notifyActiveStep(context, approval, id, {
|
||||||
requestTypeId: rtId,
|
requestTypeId: rtId,
|
||||||
dataListName: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID),
|
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID)
|
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -203,6 +240,10 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
|
|||||||
if (unassigned) {
|
if (unassigned) {
|
||||||
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`;
|
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`;
|
||||||
}
|
}
|
||||||
|
const unassignedExecutor = findUnassignedStep(executors.rows);
|
||||||
|
if (unassignedExecutor) {
|
||||||
|
return `Шаг ${unassignedExecutor.order} исполнения («${unassignedExecutor.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`;
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -249,6 +290,18 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
|
|||||||
|
|
||||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
||||||
|
|
||||||
|
{/* Блок исполнителя показываем, только когда по маршруту реально есть
|
||||||
|
исполнители (условие «IsManager» и т.п. могло не назначить ни одного). */}
|
||||||
|
{matrixReady && !executors.loading && executors.rows.length > 0 && (
|
||||||
|
<ApprovalMatrix
|
||||||
|
editor={executors}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
title="Исполнение (после согласования)"
|
||||||
|
roleLabel="Исполнитель"
|
||||||
|
emptyText="Исполнители не заданы по маршруту."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<SubmitActions
|
<SubmitActions
|
||||||
submit={submit}
|
submit={submit}
|
||||||
isValid={isValid}
|
isValid={isValid}
|
||||||
|
|||||||
@@ -64,13 +64,31 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
|||||||
const dataListRef = listId || dataListTitle(FORM_ID);
|
const dataListRef = listId || dataListTitle(FORM_ID);
|
||||||
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
||||||
|
|
||||||
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
|
// Матрица маршрута из фактических строк заявки (read-only), раздельно по
|
||||||
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
// фазам: согласование и исполнение — два отдельных блока в UI (см. ниже).
|
||||||
|
// decisionSeq в reloadKey: после решения строки на сервере меняются —
|
||||||
|
// перечитываем матрицу.
|
||||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||||
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]);
|
const loadApprovers = useCallback(
|
||||||
|
() =>
|
||||||
|
approval
|
||||||
|
.getMatrixForRequest(itemId)
|
||||||
|
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)),
|
||||||
|
[approval, itemId]
|
||||||
|
);
|
||||||
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||||
approval.getJobTitle(l)
|
approval.getJobTitle(l)
|
||||||
);
|
);
|
||||||
|
const loadExecutors = useCallback(
|
||||||
|
() =>
|
||||||
|
approval
|
||||||
|
.getMatrixForRequest(itemId)
|
||||||
|
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)),
|
||||||
|
[approval, itemId]
|
||||||
|
);
|
||||||
|
const executors = useMatrixEditor(loadExecutors, `e-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||||
|
approval.getJobTitle(l)
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -210,6 +228,16 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
|||||||
</FormCard>
|
</FormCard>
|
||||||
|
|
||||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||||
|
|
||||||
|
{!executors.loading && executors.rows.length > 0 && (
|
||||||
|
<ApprovalMatrix
|
||||||
|
editor={executors}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
title="Исполнение"
|
||||||
|
roleLabel="Исполнитель"
|
||||||
|
emptyText="Исполнители не назначены."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import * as React from 'react';
|
|||||||
import { CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField } from '../../../shared/ui';
|
||||||
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
|
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
|
||||||
|
|
||||||
// Должность/подразделение подтягиваются автоматически при выборе сотрудника
|
// Подразделение подтягивается автоматически при выборе сотрудника (см.
|
||||||
// (см. ClearanceForm.handleEmployeeChange — SharePoint User Profile) —
|
// ClearanceForm.handleEmployeeChange — SharePoint User Profile) — свободный
|
||||||
// свободный ввод здесь намеренно убран, чтобы не расходиться с профилем.
|
// ввод здесь намеренно убран, чтобы не расходиться с профилем.
|
||||||
const NOOP = (): void => undefined;
|
const NOOP = (): void => undefined;
|
||||||
|
|
||||||
interface IRequestSectionProps extends ISectionProps {
|
interface IRequestSectionProps extends ISectionProps {
|
||||||
@@ -32,12 +32,16 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
|
|||||||
placeholder={EMPLOYEE_PLACEHOLDER}
|
placeholder={EMPLOYEE_PLACEHOLDER}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
{/* Подтягивается из профиля при выборе сотрудника, но поле остаётся
|
||||||
|
редактируемым: если профиль не синхронизирован (SPS-JobTitle пуст),
|
||||||
|
HR должен суметь ввести должность вручную — иначе маршрут
|
||||||
|
согласования (условие «IsManager» резолвится именно по ней) не
|
||||||
|
соберётся, и список исполнителей ниже останется пустым. */}
|
||||||
<FormField label="Должность" htmlFor="Position" required>
|
<FormField label="Должность" htmlFor="Position" required>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="Position"
|
id="Position"
|
||||||
value={data.Position}
|
value={data.Position}
|
||||||
onChange={NOOP}
|
onChange={(v) => set('Position', v)}
|
||||||
readOnly
|
|
||||||
placeholder="Подставится автоматически по выбранному сотруднику"
|
placeholder="Подставится автоматически по выбранному сотруднику"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||||
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList';
|
||||||
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
import { ensureEmployee } from '../../shared/employee';
|
||||||
import { approvalListTitle, approvalListTitleForListId, dataListTitle } from '../../shared/listTitles';
|
|
||||||
import { FORM_ID, IFormData } from './model';
|
import { FORM_ID, IFormData } from './model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Экземпляр сервиса списка согласования этой формы.
|
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
|
||||||
*
|
* approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
|
||||||
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
|
* именем формы для читаемости вызывающего кода.
|
||||||
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
|
|
||||||
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
|
|
||||||
* архивной партиции имя приходится резолвить через реестр — если реестр не
|
|
||||||
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
|
|
||||||
* а не тихо откатится на текущую партицию.
|
|
||||||
*/
|
*/
|
||||||
export function clearanceApprovalService(listId?: string): ApprovalListService {
|
export function clearanceApprovalService(listId?: string): ApprovalListService {
|
||||||
return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID));
|
return approvalServiceFor(FORM_ID, listId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Отметки, которые проставляет не инициатор, а профильный согласующий (см. approverRights.ts). */
|
/** Отметки, которые проставляет не инициатор, а профильный согласующий (см. approverRights.ts). */
|
||||||
@@ -47,16 +41,10 @@ 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;
|
||||||
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
const list = resolveDataList(FORM_ID, listId);
|
||||||
await list.items.getById(id).update(item);
|
await list.items.getById(id).update(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка). */
|
|
||||||
async function ensureEmployee(login: string): Promise<{ Id: number; Title: string }> {
|
|
||||||
const full = login.indexOf('|') >= 0 ? login : `i:0#.f|membership|${login}`;
|
|
||||||
return getSP().web.ensureUser(full);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pad2(n: number): string {
|
function pad2(n: number): string {
|
||||||
return n < 10 ? `0${n}` : `${n}`;
|
return n < 10 ? `0${n}` : `${n}`;
|
||||||
}
|
}
|
||||||
@@ -83,49 +71,34 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISaveResult {
|
export type ISaveResult = ISaveItemResult;
|
||||||
id: number;
|
|
||||||
/** Задан только при создании новой заявки (существующий код не меняется). */
|
|
||||||
code?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохранить обходной лист: содержимое + маршрут согласования.
|
* Сохранить обходной лист: содержимое + маршрут (согласование + исполнение).
|
||||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||||
* Фазы исполнения у обходного листа нет — writeRoute вызывается с пустым списком исполнителей.
|
|
||||||
* Возвращает Id заявки (+ код при создании).
|
* Возвращает Id заявки (+ код при создании).
|
||||||
*
|
*
|
||||||
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadClearanceRequest). Имеет смысл
|
* listId — партиция, в которую фактически пишем: при редактировании — партиция
|
||||||
* только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции —
|
* ОБНОВЛЯЕМОЙ заявки (см. loadClearanceRequest), при создании — партиция,
|
||||||
* писать новые заявки в архив незачем и нельзя (approval-route переключает
|
* полученная от approval-route API непосредственно перед вызовом (см.
|
||||||
* текущую партицию сам, по своему порогу элементов).
|
* getCurrentListPartition в ClearanceForm.tsx) — именно этот вызов, а не кеш из
|
||||||
|
* onInit, проверяет порог элементов и запускает архивацию следующей партиции.
|
||||||
|
* Не задан (сбой запроса за текущей партицией) → откат на список по конвенции
|
||||||
|
* имён, как до появления партиционирования.
|
||||||
*/
|
*/
|
||||||
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[],
|
||||||
|
executors: IApproverToWrite[],
|
||||||
status: string,
|
status: string,
|
||||||
listId?: string
|
listId?: string
|
||||||
): Promise<ISaveResult> {
|
): Promise<ISaveResult> {
|
||||||
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);
|
||||||
|
|
||||||
let id: number;
|
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||||
let code: string | undefined;
|
await writeRoute(clearanceApprovalService(listId), result.id, approvers, executors, status);
|
||||||
if (existingId) {
|
return result;
|
||||||
await list.items.getById(existingId).update(item);
|
|
||||||
id = existingId;
|
|
||||||
} else {
|
|
||||||
const added = await list.items.add(item);
|
|
||||||
id = (added as { Id: number }).Id;
|
|
||||||
// Код известен только после создания элемента (нужен его Id).
|
|
||||||
code = buildRequestUniqueCode(requestTypeId, id, new Date());
|
|
||||||
await list.items.getById(id).update({ RequestUniqueCode: code });
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeRoute(clearanceApprovalService(existingId ? listId : undefined), id, approvers, [], status);
|
|
||||||
return { id, code };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
|
resolveWriteListId,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
@@ -186,6 +187,14 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
if (!rtId) {
|
if (!rtId) {
|
||||||
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||||
}
|
}
|
||||||
|
// requestId уже задан → редактируем существующую заявку, пишем в ту же
|
||||||
|
// партицию (editListId). Иначе — создаём новую, и партицию для записи
|
||||||
|
// спрашиваем у approval-route ПРЯМО СЕЙЧАС (а не берём из кеша onInit):
|
||||||
|
// это единственный запрос, который проверяет порог элементов текущего
|
||||||
|
// списка и ставит в очередь создание следующей партиции при достижении —
|
||||||
|
// без него архивация никогда не сработает. Сбой запроса не должен
|
||||||
|
// блокировать подачу заявки — откатываемся на список по конвенции имён.
|
||||||
|
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
||||||
const { id, code } = await saveHireRequest(
|
const { id, code } = await saveHireRequest(
|
||||||
requestId,
|
requestId,
|
||||||
rtId,
|
rtId,
|
||||||
@@ -193,7 +202,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
toApprovers(matrix.rows),
|
toApprovers(matrix.rows),
|
||||||
toApprovers(executors.rows),
|
toApprovers(executors.rows),
|
||||||
status,
|
status,
|
||||||
editListId
|
writeListId
|
||||||
);
|
);
|
||||||
setRequestId(id);
|
setRequestId(id);
|
||||||
setRequestStatus(status);
|
setRequestStatus(status);
|
||||||
@@ -201,21 +210,22 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
setRequestCode(code);
|
setRequestCode(code);
|
||||||
}
|
}
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
await uploadAttachments(editListId || dataListTitle(FORM_ID), id, files); // файлы — после создания элемента
|
await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files); // файлы — после создания элемента
|
||||||
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
||||||
setExistingAttachments(await getAttachments(editListId || dataListTitle(FORM_ID), id)); // показать добавленные
|
setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id)); // показать добавленные
|
||||||
}
|
}
|
||||||
if (onSaved) {
|
if (onSaved) {
|
||||||
onSaved();
|
onSaved();
|
||||||
}
|
}
|
||||||
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
||||||
// Имена списков — той же партиции, что и сама заявка (editListId), не текущей:
|
// Имена списков — той же партиции, куда фактически записали (writeListId),
|
||||||
// поток ищет строку маршрута именно в НЕЙ.
|
// не обязательно текущей на момент рендера формы: поток ищет строку
|
||||||
|
// маршрута именно в НЕЙ.
|
||||||
if (status === REQUEST_STATUS.onApproval) {
|
if (status === REQUEST_STATUS.onApproval) {
|
||||||
await notifyActiveStep(context, approval, id, {
|
await notifyActiveStep(context, approval, id, {
|
||||||
requestTypeId: rtId,
|
requestTypeId: rtId,
|
||||||
dataListName: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID),
|
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID),
|
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,24 +1,17 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||||
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
|
||||||
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
|
||||||
import { approvalListTitle, 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'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Экземпляр сервиса списка согласования этой формы.
|
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
|
||||||
*
|
* approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
|
||||||
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
|
* именем формы для читаемости вызывающего кода.
|
||||||
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
|
|
||||||
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
|
|
||||||
* архивной партиции имя приходится резолвить через реестр — если реестр не
|
|
||||||
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
|
|
||||||
* а не тихо откатится на текущую партицию.
|
|
||||||
*/
|
*/
|
||||||
export function hireApprovalService(listId?: string): ApprovalListService {
|
export function hireApprovalService(listId?: string): ApprovalListService {
|
||||||
return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID));
|
return approvalServiceFor(FORM_ID, listId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Осмысленный заголовок элемента списка (системная колонка Title). */
|
/** Осмысленный заголовок элемента списка (системная колонка Title). */
|
||||||
@@ -42,11 +35,7 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISaveResult {
|
export type ISaveResult = ISaveItemResult;
|
||||||
id: number;
|
|
||||||
/** Задан только при создании новой заявки (существующий код не меняется). */
|
|
||||||
code?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохранить заявку «на прием»: контент + маршрут (согласующие + исполнители) со статусом.
|
* Сохранить заявку «на прием»: контент + маршрут (согласующие + исполнители) со статусом.
|
||||||
@@ -55,10 +44,13 @@ export interface ISaveResult {
|
|||||||
* executors может быть пустым — тогда после согласования заявка станет «Согласовано».
|
* executors может быть пустым — тогда после согласования заявка станет «Согласовано».
|
||||||
* Возвращает Id заявки (+ код при создании).
|
* Возвращает Id заявки (+ код при создании).
|
||||||
*
|
*
|
||||||
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadHireRequest). Имеет смысл
|
* listId — партиция, в которую фактически пишем: при редактировании — партиция
|
||||||
* только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции —
|
* ОБНОВЛЯЕМОЙ заявки (см. loadHireRequest), при создании — партиция, полученная
|
||||||
* писать новые заявки в архив незачем и нельзя (approval-route переключает
|
* от approval-route API непосредственно перед вызовом (см. getCurrentListPartition
|
||||||
* текущую партицию сам, по своему порогу элементов).
|
* в RecruitingHireForm.tsx) — именно этот вызов, а не кеш из onInit, проверяет
|
||||||
|
* порог элементов и запускает архивацию следующей партиции.
|
||||||
|
* Не задан (сбой запроса за текущей партицией) → откат на список по конвенции
|
||||||
|
* имён, как до появления партиционирования.
|
||||||
*/
|
*/
|
||||||
export async function saveHireRequest(
|
export async function saveHireRequest(
|
||||||
existingId: number | undefined,
|
existingId: number | undefined,
|
||||||
@@ -69,23 +61,9 @@ export async function saveHireRequest(
|
|||||||
status: string,
|
status: string,
|
||||||
listId?: string
|
listId?: string
|
||||||
): Promise<ISaveResult> {
|
): Promise<ISaveResult> {
|
||||||
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;
|
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||||
let code: string | undefined;
|
await writeRoute(hireApprovalService(listId), result.id, approvers, executors, status);
|
||||||
if (existingId) {
|
return result;
|
||||||
await list.items.getById(existingId).update(item);
|
|
||||||
id = existingId;
|
|
||||||
} else {
|
|
||||||
const added = await list.items.add(item);
|
|
||||||
id = (added as { Id: number }).Id;
|
|
||||||
// Код известен только после создания элемента (нужен его Id).
|
|
||||||
code = buildRequestUniqueCode(requestTypeId, id, new Date());
|
|
||||||
await list.items.getById(id).update({ RequestUniqueCode: code });
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeRoute(hireApprovalService(existingId ? listId : undefined), id, approvers, executors, status);
|
|
||||||
return { id, code };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
|
resolveWriteListId,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
@@ -123,9 +124,9 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
|||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login)
|
||||||
);
|
);
|
||||||
|
|
||||||
const isValid = !!(data.Responsible && data.DepartmentName.trim() && data.Position.trim());
|
const isValid = !!(data.Responsible && data.DepartmentName.trim() && data.Position.trim() && data.Residence);
|
||||||
const validate = (): string | undefined => {
|
const validate = (): string | undefined => {
|
||||||
if (!isValid) return 'Заполните обязательные поля: ответственный за подачу, название отдела, должность.';
|
if (!isValid) return 'Заполните обязательные поля: ответственный за подачу, название отдела, должность, проживание.';
|
||||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -160,13 +161,21 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
|||||||
if (!rtId) {
|
if (!rtId) {
|
||||||
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||||
}
|
}
|
||||||
|
// requestId уже задан → редактируем существующую заявку, пишем в ту же
|
||||||
|
// партицию (editListId). Иначе — создаём новую, и партицию для записи
|
||||||
|
// спрашиваем у approval-route ПРЯМО СЕЙЧАС (а не берём из кеша onInit):
|
||||||
|
// это единственный запрос, который проверяет порог элементов текущего
|
||||||
|
// списка и ставит в очередь создание следующей партиции при достижении —
|
||||||
|
// без него архивация никогда не сработает. Сбой запроса не должен
|
||||||
|
// блокировать подачу заявки — откатываемся на список по конвенции имён.
|
||||||
|
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
||||||
const { id, code } = await saveSelectionRequest(
|
const { id, code } = await saveSelectionRequest(
|
||||||
requestId,
|
requestId,
|
||||||
rtId,
|
rtId,
|
||||||
data,
|
data,
|
||||||
toApprovers(matrix.rows),
|
toApprovers(matrix.rows),
|
||||||
status,
|
status,
|
||||||
editListId
|
writeListId
|
||||||
);
|
);
|
||||||
setRequestId(id);
|
setRequestId(id);
|
||||||
setRequestStatus(status);
|
setRequestStatus(status);
|
||||||
@@ -174,21 +183,22 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
|||||||
setRequestCode(code);
|
setRequestCode(code);
|
||||||
}
|
}
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
await uploadAttachments(editListId || dataListTitle(FORM_ID), id, files); // файлы — после создания элемента
|
await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files); // файлы — после создания элемента
|
||||||
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
||||||
setExistingAttachments(await getAttachments(editListId || dataListTitle(FORM_ID), id));
|
setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id));
|
||||||
}
|
}
|
||||||
if (onSaved) {
|
if (onSaved) {
|
||||||
onSaved();
|
onSaved();
|
||||||
}
|
}
|
||||||
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
||||||
// Имена списков — той же партиции, что и сама заявка (editListId), не текущей:
|
// Имена списков — той же партиции, куда фактически записали (writeListId),
|
||||||
// поток ищет строку маршрута именно в НЕЙ.
|
// не обязательно текущей на момент рендера формы: поток ищет строку
|
||||||
|
// маршрута именно в НЕЙ.
|
||||||
if (status === REQUEST_STATUS.onApproval) {
|
if (status === REQUEST_STATUS.onApproval) {
|
||||||
await notifyActiveStep(context, approval, id, {
|
await notifyActiveStep(context, approval, id, {
|
||||||
requestTypeId: rtId,
|
requestTypeId: rtId,
|
||||||
dataListName: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID),
|
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID)
|
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,28 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
|
import { ICompany } from '../../shared/dictionary';
|
||||||
import { dataListTitle } from '../../shared/listTitles';
|
import { dataListTitle } from '../../shared/listTitles';
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA, ILanguageLevel } from './model';
|
||||||
|
|
||||||
|
/** Безопасный разбор JSON-массива из Note-колонки (пустой при ошибке/пустом). */
|
||||||
|
function parseJsonArray<T>(raw: unknown): T[] {
|
||||||
|
if (typeof raw !== 'string' || raw.trim() === '') return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? (parsed as T[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Категории полей — определяют, как читать значение из элемента списка.
|
// Категории полей — определяют, как читать значение из элемента списка.
|
||||||
const PERSON_FIELDS: (keyof IFormData)[] = ['Responsible', 'ProposedMentor', 'DirectManager'];
|
const PERSON_FIELDS: (keyof IFormData)[] = ['Responsible', 'ProposedMentor', 'DirectManager'];
|
||||||
const MULTI_PERSON_FIELDS: (keyof IFormData)[] = ['HiringDecisionMaker'];
|
const MULTI_PERSON_FIELDS: (keyof IFormData)[] = ['HiringDecisionMaker'];
|
||||||
const BOOL_FIELDS: (keyof IFormData)[] = ['IsRegistered', 'IsApproved', 'CompletionMark', 'MaterialResponsibility'];
|
const BOOL_FIELDS: (keyof IFormData)[] = [
|
||||||
|
'MaterialResponsibility',
|
||||||
|
'PieceworkPay',
|
||||||
|
'ReferralProgram',
|
||||||
|
'BusinessTrips'
|
||||||
|
];
|
||||||
const DATE_FIELDS: (keyof IFormData)[] = ['SubmitDate', 'ClosingDeadline'];
|
const DATE_FIELDS: (keyof IFormData)[] = ['SubmitDate', 'ClosingDeadline'];
|
||||||
|
|
||||||
type PersonValue = { EMail?: string } | undefined;
|
type PersonValue = { EMail?: string } | undefined;
|
||||||
@@ -30,21 +47,39 @@ export async function loadSelectionRequest(itemId: number, listId?: string): Pro
|
|||||||
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;
|
||||||
|
|
||||||
// Скалярные поля — по имени; поля-люди — вложенный EMail (+ expand).
|
// Скалярные поля — по имени; поля-люди — вложенный EMail (+ expand);
|
||||||
const scalarSelects = keys.filter((k) => !isPerson(k));
|
// Companies/Languages/Programs хранятся в Note-колонках *Json (не одноимёнными колонками).
|
||||||
|
const jsonKeys: (keyof IFormData)[] = ['Companies', 'Languages', 'Programs', 'WorkplaceProvision'];
|
||||||
|
const scalarSelects = keys.filter((k) => !isPerson(k) && jsonKeys.indexOf(k) === -1);
|
||||||
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 list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||||
const item = (await list
|
const item = (await list
|
||||||
.items.getById(itemId)
|
.items.getById(itemId)
|
||||||
.select(...scalarSelects, 'RequestStatus', 'RequestUniqueCode', ...personSelects)
|
.select(
|
||||||
|
...scalarSelects,
|
||||||
|
'CompaniesJson',
|
||||||
|
'LanguagesJson',
|
||||||
|
'ProgramsJson',
|
||||||
|
'WorkplaceProvisionJson',
|
||||||
|
'RequestStatus',
|
||||||
|
'RequestUniqueCode',
|
||||||
|
...personSelects
|
||||||
|
)
|
||||||
.expand(...expands)()) as Record<string, unknown>;
|
.expand(...expands)()) as Record<string, unknown>;
|
||||||
|
|
||||||
const data: IFormData = { ...EMPTY_DATA };
|
const data: IFormData = { ...EMPTY_DATA };
|
||||||
const writable = data as unknown as Record<string, unknown>;
|
const writable = data as unknown as Record<string, unknown>;
|
||||||
|
writable.Companies = parseJsonArray<ICompany>(item.CompaniesJson);
|
||||||
|
writable.Languages = parseJsonArray<ILanguageLevel>(item.LanguagesJson);
|
||||||
|
writable.Programs = parseJsonArray<string>(item.ProgramsJson);
|
||||||
|
writable.WorkplaceProvision = parseJsonArray<string>(item.WorkplaceProvisionJson);
|
||||||
|
|
||||||
keys.forEach((key) => {
|
keys.forEach((key) => {
|
||||||
|
if (jsonKeys.indexOf(key) !== -1) {
|
||||||
|
return; // уже разобрали из *Json
|
||||||
|
}
|
||||||
if (PERSON_FIELDS.indexOf(key) !== -1) {
|
if (PERSON_FIELDS.indexOf(key) !== -1) {
|
||||||
writable[key] = (item[key] as PersonValue)?.EMail || '';
|
writable[key] = (item[key] as PersonValue)?.EMail || '';
|
||||||
} else if (MULTI_PERSON_FIELDS.indexOf(key) !== -1) {
|
} else if (MULTI_PERSON_FIELDS.indexOf(key) !== -1) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { ISelectOption } from '../../shared/ui';
|
import { ISelectOption } from '../../shared/ui';
|
||||||
|
import { ICompany } from '../../shared/dictionary';
|
||||||
import { RequestTypeId } from '../../models/types';
|
import { RequestTypeId } from '../../models/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,47 +19,47 @@ export interface IFormData {
|
|||||||
SubmitDate: string;
|
SubmitDate: string;
|
||||||
ClosingDeadline: string;
|
ClosingDeadline: string;
|
||||||
OpenReason: string;
|
OpenReason: string;
|
||||||
IsRegistered: boolean;
|
OpenReasonDetails: string;
|
||||||
IsApproved: boolean;
|
Companies: ICompany[];
|
||||||
CompletionMark: boolean;
|
ObjectProject: string;
|
||||||
|
ObjectProjectId: string;
|
||||||
|
ObjectProjectCode: string;
|
||||||
// Отдел
|
// Отдел
|
||||||
DepartmentName: string;
|
DepartmentName: string;
|
||||||
DepartmentHeadcount: string;
|
OpenVacanciesCount: string;
|
||||||
DepartmentGoals: string;
|
|
||||||
RelatedDepartments: string;
|
|
||||||
// Вакансия
|
// Вакансия
|
||||||
Position: string;
|
Position: string;
|
||||||
ClassifierCode: string;
|
|
||||||
EmploymentType: string;
|
EmploymentType: string;
|
||||||
MainFunction: string;
|
JobFunction: string;
|
||||||
AdditionalFunction: string;
|
|
||||||
Competencies: string;
|
Competencies: string;
|
||||||
PersonalTraits: string;
|
PersonalTraits: string;
|
||||||
Education: string;
|
Education: string;
|
||||||
WorkExperience: string;
|
WorkExperience: string;
|
||||||
AdditionalEducation: string;
|
Programs: string[];
|
||||||
EvaluationCriteria: string;
|
Languages: ILanguageLevel[];
|
||||||
PcSkills: string;
|
|
||||||
ForeignLanguages: string;
|
|
||||||
Residence: string;
|
Residence: string;
|
||||||
|
ResidenceOther: string;
|
||||||
DrivingLicense: string;
|
DrivingLicense: string;
|
||||||
// Здоровье / командировки
|
// Командировки
|
||||||
HealthRestrictions: string;
|
BusinessTrips: boolean;
|
||||||
BusinessTrips: string;
|
BusinessTripsDetails: string;
|
||||||
|
BusinessTripsDetailsOther: string;
|
||||||
// Условия работы
|
// Условия работы
|
||||||
RateProbation: string;
|
RateProbation: string;
|
||||||
RateAfterProbation: string;
|
RateAfterProbation: string;
|
||||||
Awards: string;
|
Awards: string;
|
||||||
Bonuses: string;
|
Bonuses: string;
|
||||||
CompensationPackage: string;
|
CompensationPackage: string;
|
||||||
|
PieceworkPay: boolean;
|
||||||
WorkSchedule: string;
|
WorkSchedule: string;
|
||||||
Location: string;
|
WorkScheduleOther: string;
|
||||||
|
WorkHours: string;
|
||||||
|
WorkHoursOther: string;
|
||||||
MaterialResponsibility: boolean;
|
MaterialResponsibility: boolean;
|
||||||
WorkplaceProvision: string;
|
WorkplaceProvision: string[];
|
||||||
ProposedMentor: string;
|
ProposedMentor: string;
|
||||||
DirectManager: string;
|
DirectManager: string;
|
||||||
DevelopmentPrograms: string;
|
ReferralProgram: boolean;
|
||||||
DesiredTraining: string;
|
|
||||||
HiringDecisionMaker: string[];
|
HiringDecisionMaker: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,43 +78,43 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
SubmitDate: '',
|
SubmitDate: '',
|
||||||
ClosingDeadline: '',
|
ClosingDeadline: '',
|
||||||
OpenReason: '',
|
OpenReason: '',
|
||||||
IsRegistered: false,
|
OpenReasonDetails: '',
|
||||||
IsApproved: false,
|
Companies: [],
|
||||||
CompletionMark: false,
|
ObjectProject: '',
|
||||||
|
ObjectProjectId: '',
|
||||||
|
ObjectProjectCode: '',
|
||||||
DepartmentName: '',
|
DepartmentName: '',
|
||||||
DepartmentHeadcount: '',
|
OpenVacanciesCount: '',
|
||||||
DepartmentGoals: '',
|
|
||||||
RelatedDepartments: '',
|
|
||||||
Position: '',
|
Position: '',
|
||||||
ClassifierCode: '',
|
|
||||||
EmploymentType: '',
|
EmploymentType: '',
|
||||||
MainFunction: '',
|
JobFunction: '',
|
||||||
AdditionalFunction: '',
|
|
||||||
Competencies: '',
|
Competencies: '',
|
||||||
PersonalTraits: '',
|
PersonalTraits: '',
|
||||||
Education: '',
|
Education: '',
|
||||||
WorkExperience: '',
|
WorkExperience: '',
|
||||||
AdditionalEducation: '',
|
Programs: [],
|
||||||
EvaluationCriteria: '',
|
Languages: [],
|
||||||
PcSkills: '',
|
|
||||||
ForeignLanguages: '',
|
|
||||||
Residence: '',
|
Residence: '',
|
||||||
|
ResidenceOther: '',
|
||||||
DrivingLicense: '',
|
DrivingLicense: '',
|
||||||
HealthRestrictions: '',
|
BusinessTrips: false,
|
||||||
BusinessTrips: '',
|
BusinessTripsDetails: '',
|
||||||
|
BusinessTripsDetailsOther: '',
|
||||||
RateProbation: '',
|
RateProbation: '',
|
||||||
RateAfterProbation: '',
|
RateAfterProbation: '',
|
||||||
Awards: '',
|
Awards: '',
|
||||||
Bonuses: '',
|
Bonuses: '',
|
||||||
CompensationPackage: '',
|
CompensationPackage: '',
|
||||||
|
PieceworkPay: false,
|
||||||
WorkSchedule: '',
|
WorkSchedule: '',
|
||||||
Location: '',
|
WorkScheduleOther: '',
|
||||||
|
WorkHours: '',
|
||||||
|
WorkHoursOther: '',
|
||||||
MaterialResponsibility: false,
|
MaterialResponsibility: false,
|
||||||
WorkplaceProvision: '',
|
WorkplaceProvision: [],
|
||||||
ProposedMentor: '',
|
ProposedMentor: '',
|
||||||
DirectManager: '',
|
DirectManager: '',
|
||||||
DevelopmentPrograms: '',
|
ReferralProgram: false,
|
||||||
DesiredTraining: '',
|
|
||||||
HiringDecisionMaker: []
|
HiringDecisionMaker: []
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,5 +124,127 @@ export const EMPLOYMENT_TYPE_OPTIONS: ISelectOption[] = [
|
|||||||
{ value: 'Временная', label: 'Временная' }
|
{ value: 'Временная', label: 'Временная' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Значение-триггер «Другое»: при нём показываем поле ручного ввода. */
|
||||||
|
export const OTHER_VALUE = 'Другое';
|
||||||
|
|
||||||
|
/** Причины открытия вакансии, требующие ФИО сотрудника (для условного поля). */
|
||||||
|
export const OPEN_REASON_REPLACE = 'Замена уволенного сотрудника';
|
||||||
|
export const OPEN_REASON_TRANSFER = 'Перевод сотрудника';
|
||||||
|
export const OPEN_REASON_NEW = 'Новая штатная единица';
|
||||||
|
|
||||||
|
/** Варианты «Причина открытия вакансии» — совпадают с choices колонки OpenReason. */
|
||||||
|
export const OPEN_REASON_OPTIONS: ISelectOption[] = [
|
||||||
|
{ value: OPEN_REASON_REPLACE, label: OPEN_REASON_REPLACE },
|
||||||
|
{ value: OPEN_REASON_TRANSFER, label: OPEN_REASON_TRANSFER },
|
||||||
|
{ value: OPEN_REASON_NEW, label: OPEN_REASON_NEW },
|
||||||
|
{ value: OTHER_VALUE, label: OTHER_VALUE }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Варианты «Проживание» — совпадают с choices колонки Residence. */
|
||||||
|
export const RESIDENCE_OPTIONS: ISelectOption[] = [
|
||||||
|
{ value: 'Общежитие компании', label: 'Общежитие компании' },
|
||||||
|
{ value: 'За счёт компании', label: 'За счёт компании' },
|
||||||
|
{ value: 'За счёт кандидата', label: 'За счёт кандидата' },
|
||||||
|
{ value: OTHER_VALUE, label: OTHER_VALUE }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Владение одним языком: язык + требуемый уровень. Хранится JSON-массивом. */
|
||||||
|
export interface ILanguageLevel {
|
||||||
|
language: string;
|
||||||
|
level: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Иностранные языки, актуальные для Казахстана (фикс. набор строк таблицы). */
|
||||||
|
export const LANGUAGES: string[] = ['Казахский', 'Русский', 'Английский', 'Китайский', 'Турецкий'];
|
||||||
|
|
||||||
|
/** Шкала уровней владения языком. */
|
||||||
|
export const LANGUAGE_LEVEL_OPTIONS: ISelectOption[] = [
|
||||||
|
{ value: 'Начинающий', label: 'Начинающий' },
|
||||||
|
{ value: 'Базовый', label: 'Базовый' },
|
||||||
|
{ value: 'Средний', label: 'Средний' },
|
||||||
|
{ value: 'Продвинутый', label: 'Продвинутый' },
|
||||||
|
{ value: 'Свободный', label: 'Свободный' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Программы (ПО) — ВРЕМЕННЫЙ список, пока нет справочника в API. Используется в
|
||||||
|
* мультивыборе «Необходимые знания в программах». Меняется здесь одним местом.
|
||||||
|
*/
|
||||||
|
export const SOFTWARE_OPTIONS: ISelectOption[] = [
|
||||||
|
{ value: '1С:Предприятие', label: '1С:Предприятие' },
|
||||||
|
{ value: 'MS Word', label: 'MS Word' },
|
||||||
|
{ value: 'MS Excel', label: 'MS Excel' },
|
||||||
|
{ value: 'MS PowerPoint', label: 'MS PowerPoint' },
|
||||||
|
{ value: 'MS Outlook', label: 'MS Outlook' },
|
||||||
|
{ value: 'MS Project', label: 'MS Project' },
|
||||||
|
{ value: 'AutoCAD', label: 'AutoCAD' },
|
||||||
|
{ value: 'Revit', label: 'Revit' },
|
||||||
|
{ value: 'ArchiCAD', label: 'ArchiCAD' },
|
||||||
|
{ value: 'Компас-3D', label: 'Компас-3D' },
|
||||||
|
{ value: 'Primavera', label: 'Primavera' },
|
||||||
|
{ value: 'SAP', label: 'SAP' },
|
||||||
|
{ value: 'Adobe Photoshop', label: 'Adobe Photoshop' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Категории «Обеспечение рабочего места» (+ «Другое» через allowOther). */
|
||||||
|
export const WORKPLACE_PROVISION_OPTIONS: ISelectOption[] = [
|
||||||
|
{ value: 'Оргтехника', label: 'Оргтехника' },
|
||||||
|
{ value: 'СИЗ', label: 'СИЗ' },
|
||||||
|
{ value: 'Программное обеспечение', label: 'Программное обеспечение' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Варианты «Командировки — продолжительность/частота» — совпадают с choices BusinessTripsDetails. */
|
||||||
|
export const BUSINESS_TRIP_OPTIONS: ISelectOption[] = [
|
||||||
|
{ value: 'Редко (1–2 раза в год)', label: 'Редко (1–2 раза в год)' },
|
||||||
|
{ value: 'Ежеквартально', label: 'Ежеквартально' },
|
||||||
|
{ value: 'Ежемесячно', label: 'Ежемесячно' },
|
||||||
|
{ value: 'Еженедельно', label: 'Еженедельно' },
|
||||||
|
{ value: 'Постоянные разъезды', label: 'Постоянные разъезды' },
|
||||||
|
{ value: 'Вахтовый метод', label: 'Вахтовый метод' },
|
||||||
|
{ value: OTHER_VALUE, label: OTHER_VALUE }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Варианты «Образование» (РК) — совпадают с choices колонки Education. */
|
||||||
|
export const EDUCATION_OPTIONS: ISelectOption[] = [
|
||||||
|
{ value: 'Среднее', label: 'Среднее' },
|
||||||
|
{ value: 'Техническое и профессиональное (ТиПО)', label: 'Техническое и профессиональное (ТиПО)' },
|
||||||
|
{ value: 'Незаконченное высшее', label: 'Незаконченное высшее' },
|
||||||
|
{ value: 'Высшее — бакалавриат', label: 'Высшее — бакалавриат' },
|
||||||
|
{ value: 'Высшее — магистратура', label: 'Высшее — магистратура' },
|
||||||
|
{ value: 'Послевузовское (докторантура / PhD)', label: 'Послевузовское (докторантура / PhD)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Варианты «Опыт работы» — совпадают с choices колонки WorkExperience. */
|
||||||
|
export const WORK_EXPERIENCE_OPTIONS: ISelectOption[] = [
|
||||||
|
{ value: 'Без опыта', label: 'Без опыта' },
|
||||||
|
{ value: 'До 3 лет', label: 'До 3 лет' },
|
||||||
|
{ value: 'От 3 до 5 лет', label: 'От 3 до 5 лет' },
|
||||||
|
{ value: 'От 5 лет и выше', label: 'От 5 лет и выше' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Варианты «Режим работы» — совпадают с choices колонки WorkSchedule. */
|
||||||
|
export const WORK_SCHEDULE_OPTIONS: ISelectOption[] = [
|
||||||
|
{ value: '5/2', label: '5/2' },
|
||||||
|
{ value: 'Вахтовый 30/30', label: 'Вахтовый 30/30' },
|
||||||
|
{ value: '20/10', label: '20/10' },
|
||||||
|
{ value: '30/15', label: '30/15' },
|
||||||
|
{ value: '22/8', label: '22/8' },
|
||||||
|
{ value: '15/15', label: '15/15' },
|
||||||
|
{ value: '6/1', label: '6/1' },
|
||||||
|
{ value: '1/2', label: '1/2' },
|
||||||
|
{ value: '2/2', label: '2/2' },
|
||||||
|
{ value: OTHER_VALUE, label: OTHER_VALUE }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Варианты «Время работы» — совпадают с choices колонки WorkHours. */
|
||||||
|
export const WORK_HOURS_OPTIONS: ISelectOption[] = [
|
||||||
|
{ value: '8-5', label: '8-5' },
|
||||||
|
{ value: '9-6', label: '9-6' },
|
||||||
|
{ value: '7-7', label: '7-7' },
|
||||||
|
{ value: '8-8', label: '8-8' },
|
||||||
|
{ value: '9-9', label: '9-9' },
|
||||||
|
{ value: OTHER_VALUE, label: OTHER_VALUE }
|
||||||
|
];
|
||||||
|
|
||||||
/** Плейсхолдер для полей-людей. */
|
/** Плейсхолдер для полей-людей. */
|
||||||
export const PERSON_PLACEHOLDER = 'Начните вводить имя сотрудника…';
|
export const PERSON_PLACEHOLDER = 'Начните вводить имя сотрудника…';
|
||||||
|
|||||||
@@ -5,49 +5,49 @@
|
|||||||
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
||||||
{ "internalName": "Responsible", "title": "Ответственный за подачу заявки", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
|
{ "internalName": "Responsible", "title": "Ответственный за подачу заявки", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
|
||||||
{ "internalName": "SubmitDate", "title": "Дата подачи заявки", "type": "DateTime", "dateOnly": true },
|
{ "internalName": "SubmitDate", "title": "Дата подачи заявки", "type": "DateTime", "dateOnly": true },
|
||||||
{ "internalName": "IsRegistered", "title": "Зарегистрирована", "type": "Boolean" },
|
{ "internalName": "OpenReason", "title": "Причина открытия вакансии", "type": "Choice", "choices": ["Замена уволенного сотрудника", "Перевод сотрудника", "Новая штатная единица", "Другое"] },
|
||||||
{ "internalName": "IsApproved", "title": "Одобрена", "type": "Boolean" },
|
{ "internalName": "OpenReasonDetails", "title": "Причина открытия — ФИО / уточнение", "type": "Text" },
|
||||||
{ "internalName": "OpenReason", "title": "Причина открытия вакансии", "type": "Note" },
|
{ "internalName": "CompaniesJson", "title": "Компании (JSON)", "type": "Note" },
|
||||||
|
{ "internalName": "ObjectProject", "title": "Объект/Проект", "type": "Text" },
|
||||||
|
{ "internalName": "ObjectProjectId", "title": "Объект/Проект — Id", "type": "Text" },
|
||||||
|
{ "internalName": "ObjectProjectCode", "title": "Объект/Проект — код", "type": "Text" },
|
||||||
{ "internalName": "ClosingDeadline", "title": "Крайний срок закрытия", "type": "DateTime", "dateOnly": true },
|
{ "internalName": "ClosingDeadline", "title": "Крайний срок закрытия", "type": "DateTime", "dateOnly": true },
|
||||||
{ "internalName": "CompletionMark", "title": "Отметка о выполнении", "type": "Boolean" },
|
|
||||||
|
|
||||||
{ "internalName": "DepartmentName", "title": "Название отдела/департамента", "type": "Text", "required": true },
|
{ "internalName": "DepartmentName", "title": "Название отдела/департамента", "type": "Text", "required": true },
|
||||||
{ "internalName": "DepartmentHeadcount", "title": "Количество сотрудников в отделе", "type": "Number" },
|
{ "internalName": "OpenVacanciesCount", "title": "Количество открытых вакансий", "type": "Number" },
|
||||||
{ "internalName": "DepartmentGoals", "title": "Цели, стоящие перед отделом", "type": "Note" },
|
|
||||||
{ "internalName": "RelatedDepartments", "title": "Смежные отделы по бизнес-процессам", "type": "Note" },
|
|
||||||
|
|
||||||
{ "internalName": "Position", "title": "Должность", "type": "Text", "required": true },
|
{ "internalName": "Position", "title": "Должность", "type": "Text", "required": true },
|
||||||
{ "internalName": "ClassifierCode", "title": "Код по классификатору", "type": "Text" },
|
|
||||||
{ "internalName": "EmploymentType", "title": "Характер занятости", "type": "Choice", "choices": ["Постоянная", "Временная"] },
|
{ "internalName": "EmploymentType", "title": "Характер занятости", "type": "Choice", "choices": ["Постоянная", "Временная"] },
|
||||||
{ "internalName": "MainFunction", "title": "Основной функционал", "type": "Note" },
|
{ "internalName": "JobFunction", "title": "Функционал", "type": "Note" },
|
||||||
{ "internalName": "AdditionalFunction", "title": "Дополнительный функционал", "type": "Note" },
|
|
||||||
{ "internalName": "Competencies", "title": "Компетенции", "type": "Note" },
|
{ "internalName": "Competencies", "title": "Компетенции", "type": "Note" },
|
||||||
{ "internalName": "PersonalTraits", "title": "Личностные характеристики", "type": "Note" },
|
{ "internalName": "PersonalTraits", "title": "Личностные характеристики", "type": "Note" },
|
||||||
{ "internalName": "Education", "title": "Образование", "type": "Text" },
|
{ "internalName": "Education", "title": "Образование", "type": "Choice", "choices": ["Среднее", "Техническое и профессиональное (ТиПО)", "Незаконченное высшее", "Высшее — бакалавриат", "Высшее — магистратура", "Послевузовское (докторантура / PhD)"] },
|
||||||
{ "internalName": "WorkExperience", "title": "Опыт работы", "type": "Note" },
|
{ "internalName": "WorkExperience", "title": "Опыт работы", "type": "Choice", "choices": ["Без опыта", "До 3 лет", "От 3 до 5 лет", "От 5 лет и выше"] },
|
||||||
{ "internalName": "AdditionalEducation", "title": "Дополнительное образование", "type": "Note" },
|
{ "internalName": "ProgramsJson", "title": "Необходимые знания в программах (JSON)", "type": "Note" },
|
||||||
{ "internalName": "EvaluationCriteria", "title": "Критерии оценки работы", "type": "Note" },
|
{ "internalName": "LanguagesJson", "title": "Иностранные языки (JSON)", "type": "Note" },
|
||||||
{ "internalName": "PcSkills", "title": "Уровень владения ПК и программами", "type": "Note" },
|
{ "internalName": "Residence", "title": "Проживание", "type": "Choice", "required": true, "choices": ["Общежитие компании", "За счёт компании", "За счёт кандидата", "Другое"] },
|
||||||
{ "internalName": "ForeignLanguages", "title": "Иностранные языки (уровень владения)", "type": "Note" },
|
{ "internalName": "ResidenceOther", "title": "Проживание (другое)", "type": "Text" },
|
||||||
{ "internalName": "Residence", "title": "Проживание", "type": "Text" },
|
|
||||||
{ "internalName": "DrivingLicense", "title": "Водительские права, стаж вождения", "type": "Text" },
|
{ "internalName": "DrivingLicense", "title": "Водительские права, стаж вождения", "type": "Text" },
|
||||||
|
|
||||||
{ "internalName": "HealthRestrictions", "title": "Приемлемые противопоказания (здоровье)", "type": "Note" },
|
{ "internalName": "BusinessTrips", "title": "Командировки", "type": "Boolean" },
|
||||||
{ "internalName": "BusinessTrips", "title": "Командировки (есть/нет и продолжительность)", "type": "Note" },
|
{ "internalName": "BusinessTripsDetails", "title": "Командировки — продолжительность / частота", "type": "Choice", "choices": ["Редко (1–2 раза в год)", "Ежеквартально", "Ежемесячно", "Еженедельно", "Постоянные разъезды", "Вахтовый метод", "Другое"] },
|
||||||
|
{ "internalName": "BusinessTripsDetailsOther", "title": "Командировки — частота (другое)", "type": "Text" },
|
||||||
|
|
||||||
{ "internalName": "RateProbation", "title": "Ставка на ИС", "type": "Number", "decimals": 2 },
|
{ "internalName": "RateProbation", "title": "Ставка на ИС", "type": "Number", "decimals": 2 },
|
||||||
{ "internalName": "RateAfterProbation", "title": "Ставка после ИС", "type": "Number", "decimals": 2 },
|
{ "internalName": "RateAfterProbation", "title": "Ставка после ИС", "type": "Number", "decimals": 2 },
|
||||||
{ "internalName": "Awards", "title": "Премии", "type": "Note" },
|
{ "internalName": "Awards", "title": "Премии", "type": "Note" },
|
||||||
{ "internalName": "Bonuses", "title": "Бонусы", "type": "Note" },
|
{ "internalName": "Bonuses", "title": "Бонусы", "type": "Note" },
|
||||||
{ "internalName": "CompensationPackage", "title": "Компенсационный пакет", "type": "Note" },
|
{ "internalName": "CompensationPackage", "title": "Компенсационный пакет", "type": "Note" },
|
||||||
{ "internalName": "WorkSchedule", "title": "Режим работы", "type": "Text" },
|
{ "internalName": "PieceworkPay", "title": "Сдельная оплата труда", "type": "Boolean" },
|
||||||
{ "internalName": "Location", "title": "Локация", "type": "Text" },
|
{ "internalName": "WorkSchedule", "title": "Режим работы", "type": "Choice", "choices": ["5/2", "Вахтовый 30/30", "20/10", "30/15", "22/8", "15/15", "6/1", "1/2", "2/2", "Другое"] },
|
||||||
|
{ "internalName": "WorkScheduleOther", "title": "Режим работы (другое)", "type": "Text" },
|
||||||
|
{ "internalName": "WorkHours", "title": "Время работы", "type": "Choice", "choices": ["8-5", "9-6", "7-7", "8-8", "9-9", "Другое"] },
|
||||||
|
{ "internalName": "WorkHoursOther", "title": "Время работы (другое)", "type": "Text" },
|
||||||
{ "internalName": "MaterialResponsibility", "title": "Материальная ответственность", "type": "Boolean" },
|
{ "internalName": "MaterialResponsibility", "title": "Материальная ответственность", "type": "Boolean" },
|
||||||
{ "internalName": "WorkplaceProvision", "title": "Обеспечение рабочего места", "type": "Note" },
|
{ "internalName": "WorkplaceProvisionJson", "title": "Обеспечение рабочего места (JSON)", "type": "Note" },
|
||||||
{ "internalName": "ProposedMentor", "title": "Предполагаемый наставник", "type": "User", "userSelectionMode": "PeopleOnly" },
|
{ "internalName": "ProposedMentor", "title": "Предполагаемый наставник", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||||
{ "internalName": "DirectManager", "title": "Непосредственный руководитель", "type": "User", "userSelectionMode": "PeopleOnly" },
|
{ "internalName": "DirectManager", "title": "Непосредственный руководитель", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||||
{ "internalName": "DevelopmentPrograms", "title": "Программы развития и обучения, периодичность", "type": "Note" },
|
{ "internalName": "ReferralProgram", "title": "Реферальная программа", "type": "Boolean" },
|
||||||
{ "internalName": "DesiredTraining", "title": "Желаемое доп. обучение при выходе на работу", "type": "Note" },
|
|
||||||
{ "internalName": "HiringDecisionMaker", "title": "Кто принимает решение о приеме (собеседования)", "type": "User", "userSelectionMode": "PeopleOnly", "allowMultiple": true }
|
{ "internalName": "HiringDecisionMaker", "title": "Кто принимает решение о приеме (собеседования)", "type": "User", "userSelectionMode": "PeopleOnly", "allowMultiple": true }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+83
-37
@@ -1,39 +1,85 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, TextInput, TextArea, Toggle, PeopleField } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput, Select, PeopleField } from '../../../shared/ui';
|
||||||
import { ISectionProps, PERSON_PLACEHOLDER } from '../model';
|
import { CompanyPickerMulti, Project1CPicker } from '../../../shared/dictionary';
|
||||||
|
import {
|
||||||
|
ISectionProps,
|
||||||
|
PERSON_PLACEHOLDER,
|
||||||
|
OPEN_REASON_OPTIONS,
|
||||||
|
OPEN_REASON_REPLACE,
|
||||||
|
OPEN_REASON_TRANSFER,
|
||||||
|
OPEN_REASON_NEW
|
||||||
|
} from '../model';
|
||||||
|
|
||||||
export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context }) => (
|
export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context }) => {
|
||||||
<CollapseSection title="Заявка" icon="clipboard">
|
const reason = data.OpenReason;
|
||||||
<FormField label="Ответственный за подачу заявки" required full>
|
const showReasonDetails = reason !== '' && reason !== OPEN_REASON_NEW;
|
||||||
<PeopleField
|
const reasonDetailsLabel =
|
||||||
context={context}
|
reason === OPEN_REASON_REPLACE || reason === OPEN_REASON_TRANSFER ? 'ФИО сотрудника' : 'Уточните причину';
|
||||||
value={data.Responsible}
|
|
||||||
onChange={(v) => set('Responsible', v)}
|
return (
|
||||||
placeholder={PERSON_PLACEHOLDER}
|
<CollapseSection title="1. Заявка" icon="clipboard">
|
||||||
/>
|
<FormField label="1.1 Ответственный за подачу заявки" required full>
|
||||||
</FormField>
|
<PeopleField
|
||||||
<FormField label="Дата подачи заявки" htmlFor="SubmitDate">
|
context={context}
|
||||||
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} />
|
value={data.Responsible}
|
||||||
</FormField>
|
onChange={(v) => set('Responsible', v)}
|
||||||
<FormField label="Крайний срок закрытия" htmlFor="ClosingDeadline">
|
placeholder={PERSON_PLACEHOLDER}
|
||||||
<TextInput
|
/>
|
||||||
id="ClosingDeadline"
|
</FormField>
|
||||||
type="date"
|
<FormField label="1.2 Дата подачи заявки" htmlFor="SubmitDate">
|
||||||
value={data.ClosingDeadline}
|
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} />
|
||||||
onChange={(v) => set('ClosingDeadline', v)}
|
</FormField>
|
||||||
/>
|
<FormField label="1.3 Крайний срок закрытия" htmlFor="ClosingDeadline">
|
||||||
</FormField>
|
<TextInput
|
||||||
<FormField label="Причина открытия вакансии" htmlFor="OpenReason" full>
|
id="ClosingDeadline"
|
||||||
<TextArea id="OpenReason" value={data.OpenReason} onChange={(v) => set('OpenReason', v)} placeholder="..." />
|
type="date"
|
||||||
</FormField>
|
value={data.ClosingDeadline}
|
||||||
<FormField label="Зарегистрирована">
|
onChange={(v) => set('ClosingDeadline', v)}
|
||||||
<Toggle checked={data.IsRegistered} onChange={(v) => set('IsRegistered', v)} onLabel="Да" />
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Одобрена">
|
<FormField label="1.4 Причина открытия вакансии" htmlFor="OpenReason">
|
||||||
<Toggle checked={data.IsApproved} onChange={(v) => set('IsApproved', v)} onLabel="Да" />
|
<Select
|
||||||
</FormField>
|
id="OpenReason"
|
||||||
<FormField label="Отметка о выполнении">
|
value={data.OpenReason}
|
||||||
<Toggle checked={data.CompletionMark} onChange={(v) => set('CompletionMark', v)} onLabel="Да" />
|
onChange={(v) => {
|
||||||
</FormField>
|
set('OpenReason', v);
|
||||||
</CollapseSection>
|
if (v === '' || v === OPEN_REASON_NEW) set('OpenReasonDetails', '');
|
||||||
);
|
}}
|
||||||
|
options={OPEN_REASON_OPTIONS}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{showReasonDetails && (
|
||||||
|
<FormField label={reasonDetailsLabel} htmlFor="OpenReasonDetails">
|
||||||
|
<TextInput
|
||||||
|
id="OpenReasonDetails"
|
||||||
|
value={data.OpenReasonDetails}
|
||||||
|
onChange={(v) => set('OpenReasonDetails', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
<FormField label="1.5 Компания" htmlFor="Companies" full>
|
||||||
|
<CompanyPickerMulti
|
||||||
|
context={context}
|
||||||
|
id="Companies"
|
||||||
|
value={data.Companies}
|
||||||
|
onChange={(v) => set('Companies', v)}
|
||||||
|
placeholder="Начните вводить название компании…"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="1.6 Объект/Проект" htmlFor="ObjectProject" full>
|
||||||
|
<Project1CPicker
|
||||||
|
context={context}
|
||||||
|
id="ObjectProject"
|
||||||
|
value={data.ObjectProject}
|
||||||
|
onChange={(name, id, code) => {
|
||||||
|
set('ObjectProject', name);
|
||||||
|
set('ObjectProjectId', id);
|
||||||
|
set('ObjectProjectCode', code);
|
||||||
|
}}
|
||||||
|
placeholder="Начните вводить название или код проекта…"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</CollapseSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
+70
-24
@@ -1,45 +1,97 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, TextInput, TextArea, MoneyInput, Toggle, PeopleField, PeopleFieldMulti } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput, TextArea, MoneyInput, Toggle, Select, PeopleField, PeopleFieldMulti } from '../../../shared/ui';
|
||||||
import { ISectionProps, PERSON_PLACEHOLDER } from '../model';
|
import { MultiChoiceField } from './MultiChoiceField';
|
||||||
|
import {
|
||||||
|
ISectionProps,
|
||||||
|
PERSON_PLACEHOLDER,
|
||||||
|
OTHER_VALUE,
|
||||||
|
WORK_SCHEDULE_OPTIONS,
|
||||||
|
WORK_HOURS_OPTIONS,
|
||||||
|
WORKPLACE_PROVISION_OPTIONS
|
||||||
|
} from '../model';
|
||||||
|
|
||||||
export const ConditionsSection: React.FC<ISectionProps> = ({ data, set, context }) => (
|
export const ConditionsSection: React.FC<ISectionProps> = ({ data, set, context }) => (
|
||||||
<CollapseSection title="Условия работы" icon="chart">
|
<CollapseSection title="5. Условия работы" icon="chart">
|
||||||
<FormField label="Ставка на ИС" htmlFor="RateProbation">
|
<FormField label="5.1 Ставка на ИС" htmlFor="RateProbation">
|
||||||
<MoneyInput id="RateProbation" value={data.RateProbation} onChange={(v) => set('RateProbation', v)} />
|
<MoneyInput id="RateProbation" value={data.RateProbation} onChange={(v) => set('RateProbation', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Ставка после ИС" htmlFor="RateAfterProbation">
|
<FormField label="5.2 Ставка после ИС" htmlFor="RateAfterProbation">
|
||||||
<MoneyInput
|
<MoneyInput
|
||||||
id="RateAfterProbation"
|
id="RateAfterProbation"
|
||||||
value={data.RateAfterProbation}
|
value={data.RateAfterProbation}
|
||||||
onChange={(v) => set('RateAfterProbation', v)}
|
onChange={(v) => set('RateAfterProbation', v)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Премии" htmlFor="Awards" full>
|
<FormField label="5.3 Премии" htmlFor="Awards" full>
|
||||||
<TextArea id="Awards" value={data.Awards} onChange={(v) => set('Awards', v)} />
|
<TextArea id="Awards" value={data.Awards} onChange={(v) => set('Awards', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Бонусы" htmlFor="Bonuses" full>
|
<FormField label="5.4 Бонусы" htmlFor="Bonuses" full>
|
||||||
<TextArea id="Bonuses" value={data.Bonuses} onChange={(v) => set('Bonuses', v)} />
|
<TextArea id="Bonuses" value={data.Bonuses} onChange={(v) => set('Bonuses', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Компенсационный пакет" htmlFor="CompensationPackage" full>
|
<FormField label="5.5 Компенсационный пакет" htmlFor="CompensationPackage" full>
|
||||||
<TextArea id="CompensationPackage" value={data.CompensationPackage} onChange={(v) => set('CompensationPackage', v)} />
|
<TextArea id="CompensationPackage" value={data.CompensationPackage} onChange={(v) => set('CompensationPackage', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Режим работы" htmlFor="WorkSchedule">
|
<FormField label="5.6 Сдельная оплата труда">
|
||||||
<TextInput id="WorkSchedule" value={data.WorkSchedule} onChange={(v) => set('WorkSchedule', v)} />
|
<Toggle checked={data.PieceworkPay} onChange={(v) => set('PieceworkPay', v)} onLabel="Да" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Локация" htmlFor="Location">
|
<FormField label="5.7 Режим работы" htmlFor="WorkSchedule">
|
||||||
<TextInput id="Location" value={data.Location} onChange={(v) => set('Location', v)} />
|
<Select
|
||||||
|
id="WorkSchedule"
|
||||||
|
value={data.WorkSchedule}
|
||||||
|
onChange={(v) => {
|
||||||
|
set('WorkSchedule', v);
|
||||||
|
if (v !== OTHER_VALUE) set('WorkScheduleOther', '');
|
||||||
|
}}
|
||||||
|
options={WORK_SCHEDULE_OPTIONS}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Материальная ответственность">
|
{data.WorkSchedule === OTHER_VALUE && (
|
||||||
|
<FormField label="Режим работы (другое)" htmlFor="WorkScheduleOther">
|
||||||
|
<TextInput
|
||||||
|
id="WorkScheduleOther"
|
||||||
|
value={data.WorkScheduleOther}
|
||||||
|
onChange={(v) => set('WorkScheduleOther', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
<FormField label="5.8 Время работы" htmlFor="WorkHours">
|
||||||
|
<Select
|
||||||
|
id="WorkHours"
|
||||||
|
value={data.WorkHours}
|
||||||
|
onChange={(v) => {
|
||||||
|
set('WorkHours', v);
|
||||||
|
if (v !== OTHER_VALUE) set('WorkHoursOther', '');
|
||||||
|
}}
|
||||||
|
options={WORK_HOURS_OPTIONS}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{data.WorkHours === OTHER_VALUE && (
|
||||||
|
<FormField label="Время работы (другое)" htmlFor="WorkHoursOther">
|
||||||
|
<TextInput id="WorkHoursOther" value={data.WorkHoursOther} onChange={(v) => set('WorkHoursOther', v)} />
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
<FormField label="5.9 Материальная ответственность">
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={data.MaterialResponsibility}
|
checked={data.MaterialResponsibility}
|
||||||
onChange={(v) => set('MaterialResponsibility', v)}
|
onChange={(v) => set('MaterialResponsibility', v)}
|
||||||
onLabel="Да"
|
onLabel="Да"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Обеспечение рабочего места" htmlFor="WorkplaceProvision" full>
|
<FormField label="5.10 Обеспечение рабочего места" full>
|
||||||
<TextArea id="WorkplaceProvision" value={data.WorkplaceProvision} onChange={(v) => set('WorkplaceProvision', v)} />
|
<MultiChoiceField
|
||||||
|
value={data.WorkplaceProvision}
|
||||||
|
onChange={(v) => set('WorkplaceProvision', v)}
|
||||||
|
options={WORKPLACE_PROVISION_OPTIONS}
|
||||||
|
allowOther
|
||||||
|
placeholder="Выберите категорию"
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Предполагаемый наставник">
|
<FormField label="5.11 Реферальная программа">
|
||||||
|
<Toggle checked={data.ReferralProgram} onChange={(v) => set('ReferralProgram', v)} onLabel="Да" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="5.12 Предполагаемый наставник">
|
||||||
<PeopleField
|
<PeopleField
|
||||||
context={context}
|
context={context}
|
||||||
value={data.ProposedMentor}
|
value={data.ProposedMentor}
|
||||||
@@ -47,7 +99,7 @@ export const ConditionsSection: React.FC<ISectionProps> = ({ data, set, context
|
|||||||
placeholder={PERSON_PLACEHOLDER}
|
placeholder={PERSON_PLACEHOLDER}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Непосредственный руководитель">
|
<FormField label="5.13 Непосредственный руководитель">
|
||||||
<PeopleField
|
<PeopleField
|
||||||
context={context}
|
context={context}
|
||||||
value={data.DirectManager}
|
value={data.DirectManager}
|
||||||
@@ -55,13 +107,7 @@ export const ConditionsSection: React.FC<ISectionProps> = ({ data, set, context
|
|||||||
placeholder={PERSON_PLACEHOLDER}
|
placeholder={PERSON_PLACEHOLDER}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Программы развития и обучения, периодичность" htmlFor="DevelopmentPrograms" full>
|
<FormField label="5.14 Кто принимает решение о приеме (собеседования)" full>
|
||||||
<TextArea id="DevelopmentPrograms" value={data.DevelopmentPrograms} onChange={(v) => set('DevelopmentPrograms', v)} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Желаемое доп. обучение при выходе на работу" htmlFor="DesiredTraining" full>
|
|
||||||
<TextArea id="DesiredTraining" value={data.DesiredTraining} onChange={(v) => set('DesiredTraining', v)} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Кто принимает решение о приеме (собеседования)" full>
|
|
||||||
<PeopleFieldMulti
|
<PeopleFieldMulti
|
||||||
context={context}
|
context={context}
|
||||||
value={data.HiringDecisionMaker}
|
value={data.HiringDecisionMaker}
|
||||||
|
|||||||
+7
-17
@@ -1,28 +1,18 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, TextInput, TextArea } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput } from '../../../shared/ui';
|
||||||
import { ISectionProps } from '../model';
|
import { ISectionProps } from '../model';
|
||||||
|
|
||||||
export const DepartmentSection: React.FC<ISectionProps> = ({ data, set }) => (
|
export const DepartmentSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||||
<CollapseSection title="Отдел / Подразделение" icon="building">
|
<CollapseSection title="2. Отдел / Подразделение" icon="building">
|
||||||
<FormField label="Название отдела/департамента" htmlFor="DepartmentName" required full>
|
<FormField label="2.1 Название отдела/департамента" htmlFor="DepartmentName" required full>
|
||||||
<TextInput id="DepartmentName" value={data.DepartmentName} onChange={(v) => set('DepartmentName', v)} />
|
<TextInput id="DepartmentName" value={data.DepartmentName} onChange={(v) => set('DepartmentName', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Количество сотрудников в отделе" htmlFor="DepartmentHeadcount">
|
<FormField label="2.2 Количество открытых вакансий" htmlFor="OpenVacanciesCount">
|
||||||
<TextInput
|
<TextInput
|
||||||
id="DepartmentHeadcount"
|
id="OpenVacanciesCount"
|
||||||
type="number"
|
type="number"
|
||||||
value={data.DepartmentHeadcount}
|
value={data.OpenVacanciesCount}
|
||||||
onChange={(v) => set('DepartmentHeadcount', v)}
|
onChange={(v) => set('OpenVacanciesCount', v !== '' && Number(v) < 0 ? '0' : v)}
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Цели, стоящие перед отделом" htmlFor="DepartmentGoals" full>
|
|
||||||
<TextArea id="DepartmentGoals" value={data.DepartmentGoals} onChange={(v) => set('DepartmentGoals', v)} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Смежные отделы по бизнес-процессам" htmlFor="RelatedDepartments" full>
|
|
||||||
<TextArea
|
|
||||||
id="RelatedDepartments"
|
|
||||||
value={data.RelatedDepartments}
|
|
||||||
onChange={(v) => set('RelatedDepartments', v)}
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</CollapseSection>
|
</CollapseSection>
|
||||||
|
|||||||
+28
-12
@@ -1,18 +1,34 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, TextArea } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput, Select, Toggle } from '../../../shared/ui';
|
||||||
import { ISectionProps } from '../model';
|
import { ISectionProps, BUSINESS_TRIP_OPTIONS, OTHER_VALUE } from '../model';
|
||||||
|
|
||||||
export const HealthTripsSection: React.FC<ISectionProps> = ({ data, set }) => (
|
export const HealthTripsSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||||
<CollapseSection title="Здоровье и командировки" icon="send">
|
<CollapseSection title="4. Командировки" icon="send">
|
||||||
<FormField label="Приемлемые противопоказания (здоровье)" htmlFor="HealthRestrictions" full>
|
<FormField label="4.1 Командировки">
|
||||||
<TextArea
|
<Toggle checked={data.BusinessTrips} onChange={(v) => set('BusinessTrips', v)} onLabel="Да" />
|
||||||
id="HealthRestrictions"
|
|
||||||
value={data.HealthRestrictions}
|
|
||||||
onChange={(v) => set('HealthRestrictions', v)}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Командировки (есть/нет и продолжительность)" htmlFor="BusinessTrips" full>
|
|
||||||
<TextArea id="BusinessTrips" value={data.BusinessTrips} onChange={(v) => set('BusinessTrips', v)} />
|
|
||||||
</FormField>
|
</FormField>
|
||||||
|
{data.BusinessTrips && (
|
||||||
|
<FormField label="Продолжительность / частота" htmlFor="BusinessTripsDetails">
|
||||||
|
<Select
|
||||||
|
id="BusinessTripsDetails"
|
||||||
|
value={data.BusinessTripsDetails}
|
||||||
|
onChange={(v) => {
|
||||||
|
set('BusinessTripsDetails', v);
|
||||||
|
if (v !== OTHER_VALUE) set('BusinessTripsDetailsOther', '');
|
||||||
|
}}
|
||||||
|
options={BUSINESS_TRIP_OPTIONS}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
{data.BusinessTrips && data.BusinessTripsDetails === OTHER_VALUE && (
|
||||||
|
<FormField label="Продолжительность / частота (другое)" htmlFor="BusinessTripsDetailsOther">
|
||||||
|
<TextInput
|
||||||
|
id="BusinessTripsDetailsOther"
|
||||||
|
value={data.BusinessTripsDetailsOther}
|
||||||
|
onChange={(v) => set('BusinessTripsDetailsOther', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
</CollapseSection>
|
</CollapseSection>
|
||||||
);
|
);
|
||||||
|
|||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $sp-1;
|
||||||
|
margin-bottom: $sp-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $navy-50;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash {
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level {
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: $gray-500;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gray-200;
|
||||||
|
color: $gray-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Оба селекта тянутся; на узких экранах переносятся и занимают всю ширину.
|
||||||
|
.addField {
|
||||||
|
flex: 1 1 180px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import styles from './LanguagesField.module.scss';
|
||||||
|
import { Select, Button, useFormReadOnly, ISelectOption } from '../../../shared/ui';
|
||||||
|
import { ILanguageLevel, LANGUAGES, LANGUAGE_LEVEL_OPTIONS } from '../model';
|
||||||
|
|
||||||
|
export interface ILanguagesFieldProps {
|
||||||
|
value: ILanguageLevel[];
|
||||||
|
onChange: (langs: ILanguageLevel[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Иностранные языки»: строка добавления (язык + уровень → «Добавить») и список
|
||||||
|
* выбранных. Показываются только добавленные языки; уже выбранные исключаются из
|
||||||
|
* списка для добавления.
|
||||||
|
*/
|
||||||
|
export const LanguagesField: React.FC<ILanguagesFieldProps> = ({ value, onChange }) => {
|
||||||
|
const readOnly = useFormReadOnly();
|
||||||
|
const [draftLang, setDraftLang] = useState<string>('');
|
||||||
|
const [draftLevel, setDraftLevel] = useState<string>('');
|
||||||
|
|
||||||
|
const used = new Set(value.map((l) => l.language));
|
||||||
|
const langOptions: ISelectOption[] = LANGUAGES.filter((l) => !used.has(l)).map((l) => ({ value: l, label: l }));
|
||||||
|
|
||||||
|
const add = (): void => {
|
||||||
|
if (!draftLang || !draftLevel) return;
|
||||||
|
onChange([...value, { language: draftLang, level: draftLevel }]);
|
||||||
|
setDraftLang('');
|
||||||
|
setDraftLevel('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (lang: string): void => onChange(value.filter((l) => l.language !== lang));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{value.length > 0 && (
|
||||||
|
<div className={styles.list}>
|
||||||
|
{value.map((l) => (
|
||||||
|
<div key={l.language} className={styles.row}>
|
||||||
|
<span className={styles.lang}>{l.language}</span>
|
||||||
|
<span className={styles.dash}>—</span>
|
||||||
|
<span className={styles.level}>{l.level}</span>
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.remove}
|
||||||
|
aria-label={`Убрать ${l.language}`}
|
||||||
|
title="Убрать"
|
||||||
|
onClick={() => remove(l.language)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!readOnly && langOptions.length > 0 && (
|
||||||
|
<div className={styles.add}>
|
||||||
|
<div className={styles.addField}>
|
||||||
|
<Select value={draftLang} onChange={setDraftLang} options={langOptions} placeholder="Выберите язык" />
|
||||||
|
</div>
|
||||||
|
<div className={styles.addField}>
|
||||||
|
<Select
|
||||||
|
value={draftLevel}
|
||||||
|
onChange={setDraftLevel}
|
||||||
|
options={LANGUAGE_LEVEL_OPTIONS}
|
||||||
|
placeholder="Уровень"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="secondary" onClick={add} disabled={!draftLang || !draftLevel}>
|
||||||
|
Добавить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import styles from './LanguagesField.module.scss';
|
||||||
|
import { Select, TextInput, Button, useFormReadOnly, ISelectOption } from '../../../shared/ui';
|
||||||
|
import { OTHER_VALUE } from '../model';
|
||||||
|
|
||||||
|
export interface IMultiChoiceFieldProps {
|
||||||
|
value: string[];
|
||||||
|
onChange: (values: string[]) => void;
|
||||||
|
options: ISelectOption[];
|
||||||
|
/** Разрешить «Другое» с ручным вводом произвольного значения. */
|
||||||
|
allowOther?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мультивыбор из фиксированного списка: выбор значения → «Добавить», выбранные —
|
||||||
|
* чипами с удалением. Уже выбранные исключаются из списка. allowOther добавляет
|
||||||
|
* пункт «Другое» с полем ручного ввода. Переиспользует стили LanguagesField.
|
||||||
|
*/
|
||||||
|
export const MultiChoiceField: React.FC<IMultiChoiceFieldProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
allowOther,
|
||||||
|
placeholder
|
||||||
|
}) => {
|
||||||
|
const readOnly = useFormReadOnly();
|
||||||
|
const [draft, setDraft] = useState<string>('');
|
||||||
|
const [draftOther, setDraftOther] = useState<string>('');
|
||||||
|
|
||||||
|
const selected = new Set(value);
|
||||||
|
const available = options.filter((o) => !selected.has(o.value));
|
||||||
|
const addOptions: ISelectOption[] = allowOther ? [...available, { value: OTHER_VALUE, label: OTHER_VALUE }] : available;
|
||||||
|
|
||||||
|
const isOther = draft === OTHER_VALUE;
|
||||||
|
const pending = isOther ? draftOther.trim() : draft;
|
||||||
|
|
||||||
|
const add = (): void => {
|
||||||
|
if (!pending || selected.has(pending)) return;
|
||||||
|
onChange([...value, pending]);
|
||||||
|
setDraft('');
|
||||||
|
setDraftOther('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (v: string): void => onChange(value.filter((x) => x !== v));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{value.length > 0 && (
|
||||||
|
<div className={styles.list}>
|
||||||
|
{value.map((v) => (
|
||||||
|
<div key={v} className={styles.row}>
|
||||||
|
<span className={styles.lang}>{v}</span>
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.remove}
|
||||||
|
aria-label={`Убрать ${v}`}
|
||||||
|
title="Убрать"
|
||||||
|
onClick={() => remove(v)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!readOnly && addOptions.length > 0 && (
|
||||||
|
<div className={styles.add}>
|
||||||
|
<div className={styles.addField}>
|
||||||
|
<Select
|
||||||
|
value={draft}
|
||||||
|
onChange={(v) => {
|
||||||
|
setDraft(v);
|
||||||
|
if (v !== OTHER_VALUE) setDraftOther('');
|
||||||
|
}}
|
||||||
|
options={addOptions}
|
||||||
|
placeholder={placeholder ?? '— выберите —'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isOther && (
|
||||||
|
<div className={styles.addField}>
|
||||||
|
<TextInput value={draftOther} onChange={setDraftOther} placeholder="Введите значение" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button type="button" variant="secondary" onClick={add} disabled={!pending}>
|
||||||
|
Добавить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,16 +1,23 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, TextInput, TextArea, Select } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput, TextArea, Select } from '../../../shared/ui';
|
||||||
import { ISectionProps, EMPLOYMENT_TYPE_OPTIONS } from '../model';
|
import { LanguagesField } from './LanguagesField';
|
||||||
|
import { MultiChoiceField } from './MultiChoiceField';
|
||||||
|
import {
|
||||||
|
ISectionProps,
|
||||||
|
EMPLOYMENT_TYPE_OPTIONS,
|
||||||
|
EDUCATION_OPTIONS,
|
||||||
|
WORK_EXPERIENCE_OPTIONS,
|
||||||
|
RESIDENCE_OPTIONS,
|
||||||
|
SOFTWARE_OPTIONS,
|
||||||
|
OTHER_VALUE
|
||||||
|
} from '../model';
|
||||||
|
|
||||||
export const VacancySection: React.FC<ISectionProps> = ({ data, set }) => (
|
export const VacancySection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||||
<CollapseSection title="Вакансия" icon="document">
|
<CollapseSection title="3. Вакансия" icon="document">
|
||||||
<FormField label="Должность" htmlFor="Position" required full>
|
<FormField label="3.1 Должность" htmlFor="Position" required full>
|
||||||
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
|
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Код по классификатору" htmlFor="ClassifierCode">
|
<FormField label="3.2 Характер занятости" htmlFor="EmploymentType">
|
||||||
<TextInput id="ClassifierCode" value={data.ClassifierCode} onChange={(v) => set('ClassifierCode', v)} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Характер занятости" htmlFor="EmploymentType">
|
|
||||||
<Select
|
<Select
|
||||||
id="EmploymentType"
|
id="EmploymentType"
|
||||||
value={data.EmploymentType}
|
value={data.EmploymentType}
|
||||||
@@ -19,40 +26,63 @@ export const VacancySection: React.FC<ISectionProps> = ({ data, set }) => (
|
|||||||
placeholder="— выберите —"
|
placeholder="— выберите —"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Основной функционал" htmlFor="MainFunction" full>
|
<FormField label="3.3 Функционал" htmlFor="JobFunction" full>
|
||||||
<TextArea id="MainFunction" value={data.MainFunction} onChange={(v) => set('MainFunction', v)} />
|
<TextArea id="JobFunction" value={data.JobFunction} onChange={(v) => set('JobFunction', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Дополнительный функционал" htmlFor="AdditionalFunction" full>
|
<FormField label="3.4 Компетенции" htmlFor="Competencies" full>
|
||||||
<TextArea id="AdditionalFunction" value={data.AdditionalFunction} onChange={(v) => set('AdditionalFunction', v)} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Компетенции" htmlFor="Competencies" full>
|
|
||||||
<TextArea id="Competencies" value={data.Competencies} onChange={(v) => set('Competencies', v)} />
|
<TextArea id="Competencies" value={data.Competencies} onChange={(v) => set('Competencies', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Личностные характеристики" htmlFor="PersonalTraits" full>
|
<FormField label="3.5 Личностные характеристики" htmlFor="PersonalTraits" full>
|
||||||
<TextArea id="PersonalTraits" value={data.PersonalTraits} onChange={(v) => set('PersonalTraits', v)} />
|
<TextArea id="PersonalTraits" value={data.PersonalTraits} onChange={(v) => set('PersonalTraits', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Образование" htmlFor="Education">
|
<FormField label="3.6 Образование" htmlFor="Education">
|
||||||
<TextInput id="Education" value={data.Education} onChange={(v) => set('Education', v)} />
|
<Select
|
||||||
|
id="Education"
|
||||||
|
value={data.Education}
|
||||||
|
onChange={(v) => set('Education', v)}
|
||||||
|
options={EDUCATION_OPTIONS}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Опыт работы" htmlFor="WorkExperience" full>
|
<FormField label="3.7 Опыт работы" htmlFor="WorkExperience">
|
||||||
<TextArea id="WorkExperience" value={data.WorkExperience} onChange={(v) => set('WorkExperience', v)} />
|
<Select
|
||||||
|
id="WorkExperience"
|
||||||
|
value={data.WorkExperience}
|
||||||
|
onChange={(v) => set('WorkExperience', v)}
|
||||||
|
options={WORK_EXPERIENCE_OPTIONS}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Дополнительное образование" htmlFor="AdditionalEducation" full>
|
<FormField label="3.8 Необходимые знания в программах" full>
|
||||||
<TextArea id="AdditionalEducation" value={data.AdditionalEducation} onChange={(v) => set('AdditionalEducation', v)} />
|
<MultiChoiceField
|
||||||
|
value={data.Programs}
|
||||||
|
onChange={(v) => set('Programs', v)}
|
||||||
|
options={SOFTWARE_OPTIONS}
|
||||||
|
allowOther
|
||||||
|
placeholder="Выберите программу"
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Критерии оценки работы" htmlFor="EvaluationCriteria" full>
|
<FormField label="3.9 Иностранные языки" full>
|
||||||
<TextArea id="EvaluationCriteria" value={data.EvaluationCriteria} onChange={(v) => set('EvaluationCriteria', v)} />
|
<LanguagesField value={data.Languages} onChange={(v) => set('Languages', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Уровень владения ПК и программами" htmlFor="PcSkills" full>
|
<FormField label="3.10 Проживание" htmlFor="Residence" required>
|
||||||
<TextArea id="PcSkills" value={data.PcSkills} onChange={(v) => set('PcSkills', v)} />
|
<Select
|
||||||
|
id="Residence"
|
||||||
|
value={data.Residence}
|
||||||
|
onChange={(v) => {
|
||||||
|
set('Residence', v);
|
||||||
|
if (v !== OTHER_VALUE) set('ResidenceOther', '');
|
||||||
|
}}
|
||||||
|
options={RESIDENCE_OPTIONS}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Иностранные языки (уровень владения)" htmlFor="ForeignLanguages" full>
|
{data.Residence === OTHER_VALUE && (
|
||||||
<TextArea id="ForeignLanguages" value={data.ForeignLanguages} onChange={(v) => set('ForeignLanguages', v)} />
|
<FormField label="Проживание (другое)" htmlFor="ResidenceOther">
|
||||||
</FormField>
|
<TextInput id="ResidenceOther" value={data.ResidenceOther} onChange={(v) => set('ResidenceOther', v)} />
|
||||||
<FormField label="Проживание" htmlFor="Residence">
|
</FormField>
|
||||||
<TextInput id="Residence" value={data.Residence} onChange={(v) => set('Residence', v)} />
|
)}
|
||||||
</FormField>
|
<FormField label="3.11 Водительские права, стаж вождения" htmlFor="DrivingLicense">
|
||||||
<FormField label="Водительские права, стаж вождения" htmlFor="DrivingLicense">
|
|
||||||
<TextInput id="DrivingLicense" value={data.DrivingLicense} onChange={(v) => set('DrivingLicense', v)} />
|
<TextInput id="DrivingLicense" value={data.DrivingLicense} onChange={(v) => set('DrivingLicense', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</CollapseSection>
|
</CollapseSection>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||||
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
|
||||||
import { approvalListTitle, 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.
|
||||||
@@ -9,20 +8,15 @@ const PERSON_FIELDS: (keyof IFormData)[] = ['Responsible', 'ProposedMentor', 'Di
|
|||||||
// Поля-люди (несколько): логины → массив Id в колонку <Field>Id.
|
// Поля-люди (несколько): логины → массив Id в колонку <Field>Id.
|
||||||
const MULTI_PERSON_FIELDS: (keyof IFormData)[] = ['HiringDecisionMaker'];
|
const MULTI_PERSON_FIELDS: (keyof IFormData)[] = ['HiringDecisionMaker'];
|
||||||
// Числовые поля: пустая строка → null, иначе Number().
|
// Числовые поля: пустая строка → null, иначе Number().
|
||||||
const NUMBER_FIELDS: (keyof IFormData)[] = ['DepartmentHeadcount', 'RateProbation', 'RateAfterProbation'];
|
const NUMBER_FIELDS: (keyof IFormData)[] = ['OpenVacanciesCount', 'RateProbation', 'RateAfterProbation'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Экземпляр сервиса списка согласования этой формы.
|
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
|
||||||
*
|
* approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
|
||||||
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
|
* именем формы для читаемости вызывающего кода.
|
||||||
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
|
|
||||||
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
|
|
||||||
* архивной партиции имя приходится резолвить через реестр — если реестр не
|
|
||||||
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
|
|
||||||
* а не тихо откатится на текущую партицию.
|
|
||||||
*/
|
*/
|
||||||
export function selectionApprovalService(listId?: string): ApprovalListService {
|
export function selectionApprovalService(listId?: string): ApprovalListService {
|
||||||
return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID));
|
return approvalServiceFor(FORM_ID, listId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Логин → числовой Id пользователя на текущем сайте. */
|
/** Логин → числовой Id пользователя на текущем сайте. */
|
||||||
@@ -38,6 +32,26 @@ function buildScalarBody(data: IFormData): Record<string, unknown> {
|
|||||||
if (PERSON_FIELDS.indexOf(key) !== -1 || MULTI_PERSON_FIELDS.indexOf(key) !== -1) {
|
if (PERSON_FIELDS.indexOf(key) !== -1 || MULTI_PERSON_FIELDS.indexOf(key) !== -1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (key === 'Companies') {
|
||||||
|
// Мультивыбор компаний не разворачивается в колонки — сериализуем в Note.
|
||||||
|
item.CompaniesJson = data.Companies.length > 0 ? JSON.stringify(data.Companies) : null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'Languages') {
|
||||||
|
// Таблица «язык + уровень» — сериализуем в Note.
|
||||||
|
item.LanguagesJson = data.Languages.length > 0 ? JSON.stringify(data.Languages) : null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'Programs') {
|
||||||
|
// Мультивыбор программ — сериализуем в Note.
|
||||||
|
item.ProgramsJson = data.Programs.length > 0 ? JSON.stringify(data.Programs) : null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'WorkplaceProvision') {
|
||||||
|
// Мультивыбор категорий обеспечения — сериализуем в Note.
|
||||||
|
item.WorkplaceProvisionJson = data.WorkplaceProvision.length > 0 ? JSON.stringify(data.WorkplaceProvision) : null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const value = data[key];
|
const value = data[key];
|
||||||
if (typeof value === 'boolean') {
|
if (typeof value === 'boolean') {
|
||||||
item[key] = value;
|
item[key] = value;
|
||||||
@@ -66,11 +80,7 @@ async function applyPeople(item: Record<string, unknown>, data: IFormData): Prom
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISaveResult {
|
export type ISaveResult = ISaveItemResult;
|
||||||
id: number;
|
|
||||||
/** Задан только при создании новой заявки (существующий код не меняется). */
|
|
||||||
code?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохранить заявку «на подбор»: содержимое + маршрут согласования.
|
* Сохранить заявку «на подбор»: содержимое + маршрут согласования.
|
||||||
@@ -78,10 +88,13 @@ export interface ISaveResult {
|
|||||||
* status = «Черновик» (сохранение) или «На согласовании» (отправка). Фазы исполнения
|
* status = «Черновик» (сохранение) или «На согласовании» (отправка). Фазы исполнения
|
||||||
* нет — writeRoute вызывается с пустым списком исполнителей. Возвращает Id (+ код при создании).
|
* нет — writeRoute вызывается с пустым списком исполнителей. Возвращает Id (+ код при создании).
|
||||||
*
|
*
|
||||||
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadSelectionRequest). Имеет смысл
|
* listId — партиция, в которую фактически пишем: при редактировании — партиция
|
||||||
* только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции —
|
* ОБНОВЛЯЕМОЙ заявки (см. loadSelectionRequest), при создании — партиция,
|
||||||
* писать новые заявки в архив незачем и нельзя (approval-route переключает
|
* полученная от approval-route API непосредственно перед вызовом (см.
|
||||||
* текущую партицию сам, по своему порогу элементов).
|
* getCurrentListPartition в RecruitingSelectionForm.tsx) — именно этот вызов,
|
||||||
|
* а не кеш из onInit, проверяет порог элементов и запускает архивацию
|
||||||
|
* следующей партиции. Не задан (сбой запроса за текущей партицией) → откат
|
||||||
|
* на список по конвенции имён, как до появления партиционирования.
|
||||||
*/
|
*/
|
||||||
export async function saveSelectionRequest(
|
export async function saveSelectionRequest(
|
||||||
existingId: number | undefined,
|
existingId: number | undefined,
|
||||||
@@ -91,25 +104,11 @@ export async function saveSelectionRequest(
|
|||||||
status: string,
|
status: string,
|
||||||
listId?: string
|
listId?: string
|
||||||
): Promise<ISaveResult> {
|
): Promise<ISaveResult> {
|
||||||
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);
|
||||||
|
|
||||||
let id: number;
|
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||||
let code: string | undefined;
|
await writeRoute(selectionApprovalService(listId), result.id, approvers, [], status);
|
||||||
if (existingId) {
|
return result;
|
||||||
await list.items.getById(existingId).update(item);
|
|
||||||
id = existingId;
|
|
||||||
} else {
|
|
||||||
const added = await list.items.add(item);
|
|
||||||
id = (added as { Id: number }).Id;
|
|
||||||
// Код известен только после создания элемента (нужен его Id).
|
|
||||||
code = buildRequestUniqueCode(requestTypeId, id, new Date());
|
|
||||||
await list.items.getById(id).update({ RequestUniqueCode: code });
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeRoute(selectionApprovalService(existingId ? listId : undefined), id, approvers, [], status);
|
|
||||||
return { id, code };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
|
resolveWriteListId,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
@@ -225,12 +226,21 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
? REQUEST_STATUS.onApproval
|
? REQUEST_STATUS.onApproval
|
||||||
: REQUEST_STATUS.approved;
|
: REQUEST_STATUS.approved;
|
||||||
|
|
||||||
|
// requestId уже задан → редактируем существующую заявку, пишем в ту же
|
||||||
|
// партицию (editListId). Иначе — создаём новую, и партицию для записи
|
||||||
|
// спрашиваем у approval-route ПРЯМО СЕЙЧАС (а не берём из кеша onInit):
|
||||||
|
// это единственный запрос, который проверяет порог элементов текущего
|
||||||
|
// списка и ставит в очередь создание следующей партиции при достижении —
|
||||||
|
// без него архивация никогда не сработает. Сбой запроса не должен
|
||||||
|
// блокировать подачу заявки — откатываемся на список по конвенции имён.
|
||||||
|
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
||||||
|
|
||||||
const { id, code } = await saveTicketRequest(
|
const { id, code } = await saveTicketRequest(
|
||||||
requestId,
|
requestId,
|
||||||
data,
|
data,
|
||||||
toApprovers(matrix.rows),
|
toApprovers(matrix.rows),
|
||||||
finalStatus,
|
finalStatus,
|
||||||
editListId
|
writeListId
|
||||||
);
|
);
|
||||||
setRequestId(id);
|
setRequestId(id);
|
||||||
setRequestStatus(finalStatus);
|
setRequestStatus(finalStatus);
|
||||||
@@ -240,9 +250,9 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
|
|
||||||
let attachments = existingAttachments;
|
let attachments = existingAttachments;
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
await uploadAttachments(editListId || dataListTitle(FORM_ID), id, files);
|
await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files);
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
attachments = await getAttachments(editListId || dataListTitle(FORM_ID), id);
|
attachments = await getAttachments(writeListId || dataListTitle(FORM_ID), id);
|
||||||
setExistingAttachments(attachments);
|
setExistingAttachments(attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,12 +261,13 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (finalStatus === REQUEST_STATUS.onApproval) {
|
if (finalStatus === REQUEST_STATUS.onApproval) {
|
||||||
// Имена списков — той же партиции, что и сама заявка (editListId), не
|
// Имена списков — той же партиции, куда фактически записали (writeListId),
|
||||||
// текущей: поток ищет строку маршрута именно в НЕЙ.
|
// не обязательно текущей на момент рендера формы: поток ищет строку
|
||||||
|
// маршрута именно в НЕЙ.
|
||||||
await notifyActiveStep(context, approval, id, {
|
await notifyActiveStep(context, approval, id, {
|
||||||
requestTypeId: rtId,
|
requestTypeId: rtId,
|
||||||
dataListName: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID),
|
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID)
|
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
|
||||||
});
|
});
|
||||||
} else if (finalStatus === REQUEST_STATUS.approved) {
|
} else if (finalStatus === REQUEST_STATUS.approved) {
|
||||||
// Согласование не требуется для этого вида работ — сразу передаём в отдел 1С.
|
// Согласование не требуется для этого вида работ — сразу передаём в отдел 1С.
|
||||||
@@ -268,7 +279,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
attachments,
|
attachments,
|
||||||
webAbsoluteUrl: context.pageContext.web.absoluteUrl
|
webAbsoluteUrl: context.pageContext.web.absoluteUrl
|
||||||
});
|
});
|
||||||
await setPlannerTaskId(id, taskId, editListId);
|
await setPlannerTaskId(id, taskId, writeListId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||||
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList';
|
||||||
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
|
||||||
import { approvalListTitle, approvalListTitleForListId, dataListTitle } from '../../shared/listTitles';
|
|
||||||
import { FORM_ID, IFormData } from './model';
|
import { FORM_ID, IFormData } from './model';
|
||||||
|
|
||||||
// Префикс уникального кода заявки — буквенная метка «1С» вместо числового id
|
// Префикс уникального кода заявки — буквенная метка «1С» вместо числового id
|
||||||
@@ -10,17 +8,12 @@ import { FORM_ID, IFormData } from './model';
|
|||||||
export const TICKET1C_CODE_PREFIX = '1С';
|
export const TICKET1C_CODE_PREFIX = '1С';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Экземпляр сервиса списка согласования этой формы.
|
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
|
||||||
*
|
* approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
|
||||||
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
|
* именем формы для читаемости вызывающего кода.
|
||||||
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
|
|
||||||
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
|
|
||||||
* архивной партиции имя приходится резолвить через реестр — если реестр не
|
|
||||||
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
|
|
||||||
* а не тихо откатится на текущую партицию.
|
|
||||||
*/
|
*/
|
||||||
export function ticket1cApprovalService(listId?: string): ApprovalListService {
|
export function ticket1cApprovalService(listId?: string): ApprovalListService {
|
||||||
return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID));
|
return approvalServiceFor(FORM_ID, listId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Поля элемента списка данных из модели формы. TaskName пишется в системную колонку Title. */
|
/** Поля элемента списка данных из модели формы. TaskName пишется в системную колонку Title. */
|
||||||
@@ -36,11 +29,7 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISaveResult {
|
export type ISaveResult = ISaveItemResult;
|
||||||
id: number;
|
|
||||||
/** Задан только при создании новой заявки (существующий код не меняется). */
|
|
||||||
code?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохранить заявку в отдел 1С: содержимое + маршрут согласования.
|
* Сохранить заявку в отдел 1С: содержимое + маршрут согласования.
|
||||||
@@ -48,10 +37,13 @@ export interface ISaveResult {
|
|||||||
* Фазы исполнения нет: после согласования заявка уходит в Planner, а не исполнителю
|
* Фазы исполнения нет: после согласования заявка уходит в Planner, а не исполнителю
|
||||||
* из матрицы. Возвращает Id заявки (+ код при создании).
|
* из матрицы. Возвращает Id заявки (+ код при создании).
|
||||||
*
|
*
|
||||||
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadTicketRequest). Имеет смысл
|
* listId — партиция, в которую фактически пишем: при редактировании — партиция
|
||||||
* только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции —
|
* ОБНОВЛЯЕМОЙ заявки (см. loadTicketRequest), при создании — партиция,
|
||||||
* писать новые заявки в архив незачем и нельзя (approval-route переключает
|
* полученная от approval-route API непосредственно перед вызовом (см.
|
||||||
* текущую партицию сам, по своему порогу элементов).
|
* getCurrentListPartition в Ticket1CForm.tsx) — именно этот вызов, а не кеш из
|
||||||
|
* onInit, проверяет порог элементов и запускает архивацию следующей партиции.
|
||||||
|
* Не задан (сбой запроса за текущей партицией) → откат на список по конвенции
|
||||||
|
* имён, как до появления партиционирования.
|
||||||
*/
|
*/
|
||||||
export async function saveTicketRequest(
|
export async function saveTicketRequest(
|
||||||
existingId: number | undefined,
|
existingId: number | undefined,
|
||||||
@@ -60,25 +52,11 @@ export async function saveTicketRequest(
|
|||||||
status: string,
|
status: string,
|
||||||
listId?: string
|
listId?: string
|
||||||
): Promise<ISaveResult> {
|
): Promise<ISaveResult> {
|
||||||
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;
|
const result = await saveRequestItem(FORM_ID, existingId, listId, TICKET1C_CODE_PREFIX, item);
|
||||||
let code: string | undefined;
|
await writeRoute(ticket1cApprovalService(listId), result.id, approvers, [], status);
|
||||||
if (existingId) {
|
return result;
|
||||||
await list.items.getById(existingId).update(item);
|
|
||||||
id = existingId;
|
|
||||||
} else {
|
|
||||||
const added = await list.items.add(item);
|
|
||||||
id = (added as { Id: number }).Id;
|
|
||||||
// Код известен только после создания элемента (нужен его Id).
|
|
||||||
code = buildRequestUniqueCode(TICKET1C_CODE_PREFIX, id, new Date());
|
|
||||||
await list.items.getById(id).update({ RequestUniqueCode: code });
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeRoute(ticket1cApprovalService(existingId ? listId : undefined), id, approvers, [], status);
|
|
||||||
return { id, code };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,12 +65,12 @@ export async function saveTicketRequest(
|
|||||||
* решение согласующего применяется из нескольких вкладок/сессий одновременно.
|
* решение согласующего применяется из нескольких вкладок/сессий одновременно.
|
||||||
*/
|
*/
|
||||||
export async function getPlannerTaskId(id: number, listId?: string): Promise<string | undefined> {
|
export async function getPlannerTaskId(id: number, listId?: string): Promise<string | undefined> {
|
||||||
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
const list = resolveDataList(FORM_ID, listId);
|
||||||
const item = (await list.items.getById(id).select('PlannerTaskId')()) as { PlannerTaskId?: string };
|
const item = (await list.items.getById(id).select('PlannerTaskId')()) as { PlannerTaskId?: string };
|
||||||
return item.PlannerTaskId || undefined;
|
return item.PlannerTaskId || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setPlannerTaskId(id: number, taskId: string, listId?: string): Promise<void> {
|
export async function setPlannerTaskId(id: number, taskId: string, listId?: string): Promise<void> {
|
||||||
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
const list = resolveDataList(FORM_ID, listId);
|
||||||
await list.items.getById(id).update({ PlannerTaskId: taskId });
|
await list.items.getById(id).update({ PlannerTaskId: taskId });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ import {
|
|||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
IApproverToWrite,
|
IApproverToWrite,
|
||||||
APPROVAL_PHASE,
|
APPROVAL_PHASE,
|
||||||
findUnassignedStep
|
findUnassignedStep,
|
||||||
|
resolveWriteListId
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import {
|
import {
|
||||||
uploadAttachments,
|
uploadAttachments,
|
||||||
@@ -202,6 +203,10 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
|
|||||||
if (!rtId) {
|
if (!rtId) {
|
||||||
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||||
}
|
}
|
||||||
|
// Новая заявка — резолвим ТЕКУЩУЮ партицию через approval-route (заодно
|
||||||
|
// выполняется проверка порога и создание следующей архивной партиции при
|
||||||
|
// необходимости); при редактировании остаёмся в партиции самой заявки.
|
||||||
|
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
||||||
const { id, code } = await saveTmcTransferRequest(
|
const { id, code } = await saveTmcTransferRequest(
|
||||||
requestId,
|
requestId,
|
||||||
rtId,
|
rtId,
|
||||||
@@ -209,7 +214,7 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
|
|||||||
toApprovers(matrix.rows),
|
toApprovers(matrix.rows),
|
||||||
toApprovers(executors.rows),
|
toApprovers(executors.rows),
|
||||||
status,
|
status,
|
||||||
editListId
|
writeListId
|
||||||
);
|
);
|
||||||
setRequestId(id);
|
setRequestId(id);
|
||||||
setRequestStatus(status);
|
setRequestStatus(status);
|
||||||
@@ -217,21 +222,22 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
|
|||||||
setRequestCode(code);
|
setRequestCode(code);
|
||||||
}
|
}
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
await uploadAttachments(editListId || dataListTitle(FORM_ID), id, files);
|
await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files);
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
setExistingAttachments(await getAttachments(editListId || dataListTitle(FORM_ID), id));
|
setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id));
|
||||||
}
|
}
|
||||||
if (onSaved) {
|
if (onSaved) {
|
||||||
onSaved();
|
onSaved();
|
||||||
}
|
}
|
||||||
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
||||||
// Имена списков — той же партиции, что и сама заявка (editListId), не текущей:
|
// Имена списков — той же партиции, куда только что записали (writeListId),
|
||||||
// поток ищет строку маршрута именно в НЕЙ.
|
// а не editListId: для новой заявки это может быть только что созданная
|
||||||
|
// текущая партиция, отличная от editListId (который тут undefined).
|
||||||
if (status === REQUEST_STATUS.onApproval) {
|
if (status === REQUEST_STATUS.onApproval) {
|
||||||
await notifyActiveStep(context, approval, id, {
|
await notifyActiveStep(context, approval, id, {
|
||||||
requestTypeId: rtId,
|
requestTypeId: rtId,
|
||||||
dataListName: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID),
|
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID)
|
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,22 +1,15 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||||
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
|
||||||
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
|
||||||
import { ILineItem } from '../../shared/ui';
|
import { ILineItem } from '../../shared/ui';
|
||||||
import { approvalListTitle, approvalListTitleForListId, dataListTitle } from '../../shared/listTitles';
|
|
||||||
import { FORM_ID, IFormData } from './model';
|
import { FORM_ID, IFormData } from './model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Экземпляр сервиса списка согласования этой формы.
|
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
|
||||||
*
|
* approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
|
||||||
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
|
* именем формы для читаемости вызывающего кода.
|
||||||
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
|
|
||||||
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
|
|
||||||
* архивной партиции имя приходится резолвить через реестр — если реестр не
|
|
||||||
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
|
|
||||||
* а не тихо откатится на текущую партицию.
|
|
||||||
*/
|
*/
|
||||||
export function tmcTransferApprovalService(listId?: string): ApprovalListService {
|
export function tmcTransferApprovalService(listId?: string): ApprovalListService {
|
||||||
return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID));
|
return approvalServiceFor(FORM_ID, listId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Осмысленный заголовок элемента (системная колонка Title). */
|
/** Осмысленный заголовок элемента (системная колонка Title). */
|
||||||
@@ -43,21 +36,18 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISaveResult {
|
export type ISaveResult = ISaveItemResult;
|
||||||
id: number;
|
|
||||||
/** Задан только при создании новой заявки (существующий код не меняется). */
|
|
||||||
code?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохранить заявку «СЗ на перемещение ТМЦ»: контент + маршрут (согласующие + исполнители).
|
* Сохранить заявку «СЗ на перемещение ТМЦ»: контент + маршрут (согласующие + исполнители).
|
||||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||||
* Возвращает Id заявки (+ уникальный код при создании).
|
* Возвращает Id заявки (+ уникальный код при создании).
|
||||||
*
|
*
|
||||||
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadTmcTransferRequest). Имеет смысл
|
* listId — партиция, в которую пишем: для обновления существующей заявки —
|
||||||
* только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции —
|
* партиция ЭТОЙ заявки (см. loadTmcTransferRequest), для новой — ТЕКУЩАЯ
|
||||||
* писать новые заявки в архив незачем и нельзя (approval-route переключает
|
* партиция (см. getCurrentListPartition), которую вызывающая сторона резолвит
|
||||||
* текущую партицию сам, по своему порогу элементов).
|
* заранее и передаёт сюда, а не текущая-по-умолчанию: именно на этом резолве
|
||||||
|
* держится проверка порога и создание следующей архивной партиции.
|
||||||
*/
|
*/
|
||||||
export async function saveTmcTransferRequest(
|
export async function saveTmcTransferRequest(
|
||||||
existingId: number | undefined,
|
existingId: number | undefined,
|
||||||
@@ -68,23 +58,9 @@ export async function saveTmcTransferRequest(
|
|||||||
status: string,
|
status: string,
|
||||||
listId?: string
|
listId?: string
|
||||||
): Promise<ISaveResult> {
|
): Promise<ISaveResult> {
|
||||||
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;
|
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||||
let code: string | undefined;
|
await writeRoute(tmcTransferApprovalService(listId), result.id, approvers, executors, status);
|
||||||
if (existingId) {
|
return result;
|
||||||
await list.items.getById(existingId).update(item);
|
|
||||||
id = existingId;
|
|
||||||
} else {
|
|
||||||
const added = await list.items.add(item);
|
|
||||||
id = (added as { Id: number }).Id;
|
|
||||||
// Код известен только после создания элемента (нужен его Id).
|
|
||||||
code = buildRequestUniqueCode(requestTypeId, id, new Date());
|
|
||||||
await list.items.getById(id).update({ RequestUniqueCode: code });
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeRoute(tmcTransferApprovalService(existingId ? listId : undefined), id, approvers, executors, status);
|
|
||||||
return { id, code };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import {
|
|||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
IApproverToWrite,
|
IApproverToWrite,
|
||||||
findUnassignedStep
|
findUnassignedStep,
|
||||||
|
resolveWriteListId
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
import {
|
import {
|
||||||
@@ -176,13 +177,17 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
if (!rtId) {
|
if (!rtId) {
|
||||||
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||||
}
|
}
|
||||||
|
// Новая заявка пишется в ТЕКУЩУЮ партицию — запрашиваем её у approval-route
|
||||||
|
// API непосредственно перед записью (не кеш из onInit): именно этот вызов
|
||||||
|
// проверяет порог элементов и запускает архивацию следующей партиции.
|
||||||
|
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
||||||
const { id, code } = await saveUnpaidLeaveRequest(
|
const { id, code } = await saveUnpaidLeaveRequest(
|
||||||
requestId,
|
requestId,
|
||||||
rtId,
|
rtId,
|
||||||
data,
|
data,
|
||||||
toApprovers(matrix.rows),
|
toApprovers(matrix.rows),
|
||||||
status,
|
status,
|
||||||
editListId
|
writeListId
|
||||||
);
|
);
|
||||||
setRequestId(id);
|
setRequestId(id);
|
||||||
setRequestStatus(status);
|
setRequestStatus(status);
|
||||||
@@ -193,13 +198,13 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
onSaved();
|
onSaved();
|
||||||
}
|
}
|
||||||
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
||||||
// Имена списков — той же партиции, что и сама заявка (editListId), не текущей:
|
// Имена списков — той же партиции, куда фактически записана заявка (writeListId),
|
||||||
// поток ищет строку маршрута именно в НЕЙ.
|
// не текущей: поток ищет строку маршрута именно в НЕЙ.
|
||||||
if (status === REQUEST_STATUS.onApproval) {
|
if (status === REQUEST_STATUS.onApproval) {
|
||||||
await notifyActiveStep(context, approval, id, {
|
await notifyActiveStep(context, approval, id, {
|
||||||
requestTypeId: rtId,
|
requestTypeId: rtId,
|
||||||
dataListName: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID),
|
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID)
|
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,27 +1,15 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||||
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
|
||||||
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
import { ensureEmployee } from '../../shared/employee';
|
||||||
import { approvalListTitle, approvalListTitleForListId, dataListTitle } from '../../shared/listTitles';
|
|
||||||
import { FORM_ID, IFormData } from './model';
|
import { FORM_ID, IFormData } from './model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Экземпляр сервиса списка согласования этой формы.
|
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
|
||||||
*
|
* approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
|
||||||
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
|
* именем формы для читаемости вызывающего кода.
|
||||||
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
|
|
||||||
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
|
|
||||||
* архивной партиции имя приходится резолвить через реестр — если реестр не
|
|
||||||
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
|
|
||||||
* а не тихо откатится на текущую партицию.
|
|
||||||
*/
|
*/
|
||||||
export function unpaidLeaveApprovalService(listId?: string): ApprovalListService {
|
export function unpaidLeaveApprovalService(listId?: string): ApprovalListService {
|
||||||
return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID));
|
return approvalServiceFor(FORM_ID, listId);
|
||||||
}
|
|
||||||
|
|
||||||
/** email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка). */
|
|
||||||
async function ensureUser(login: string): Promise<{ Id: number; Title: string }> {
|
|
||||||
const full = login.indexOf('|') >= 0 ? login : `i:0#.f|membership|${login}`;
|
|
||||||
return getSP().web.ensureUser(full);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pad2(n: number): string {
|
function pad2(n: number): string {
|
||||||
@@ -64,11 +52,7 @@ function buildItem(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISaveResult {
|
export type ISaveResult = ISaveItemResult;
|
||||||
id: number;
|
|
||||||
/** Задан только при создании новой заявки (существующий код не меняется). */
|
|
||||||
code?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохранить заявку без содержания: содержимое + маршрут согласования.
|
* Сохранить заявку без содержания: содержимое + маршрут согласования.
|
||||||
@@ -89,26 +73,12 @@ export async function saveUnpaidLeaveRequest(
|
|||||||
status: string,
|
status: string,
|
||||||
listId?: string
|
listId?: string
|
||||||
): Promise<ISaveResult> {
|
): Promise<ISaveResult> {
|
||||||
const list =
|
const employee = await ensureEmployee(data.Employee);
|
||||||
existingId && listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
|
||||||
const employee = await ensureUser(data.Employee);
|
|
||||||
// Замещающий необязателен: резолвим только если выбран.
|
// Замещающий необязателен: резолвим только если выбран.
|
||||||
const substituteId = data.Substitute ? (await ensureUser(data.Substitute)).Id : undefined;
|
const substituteId = data.Substitute ? (await ensureEmployee(data.Substitute)).Id : undefined;
|
||||||
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee, substituteId);
|
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee, substituteId);
|
||||||
|
|
||||||
let id: number;
|
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||||
let code: string | undefined;
|
await writeRoute(unpaidLeaveApprovalService(listId), result.id, approvers, [], status);
|
||||||
if (existingId) {
|
return result;
|
||||||
await list.items.getById(existingId).update(item);
|
|
||||||
id = existingId;
|
|
||||||
} else {
|
|
||||||
const added = await list.items.add(item);
|
|
||||||
id = (added as { Id: number }).Id;
|
|
||||||
// Код известен только после создания элемента (нужен его Id).
|
|
||||||
code = buildRequestUniqueCode(requestTypeId, id, new Date());
|
|
||||||
await list.items.getById(id).update({ RequestUniqueCode: code });
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeRoute(unpaidLeaveApprovalService(existingId ? listId : undefined), id, approvers, [], status);
|
|
||||||
return { id, code };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
|
resolveWriteListId,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
@@ -173,7 +174,18 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
if (!rtId) {
|
if (!rtId) {
|
||||||
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||||
}
|
}
|
||||||
const { id, code } = await saveVacationRequest(requestId, rtId, data, toApprovers(matrix.rows), status, editListId);
|
// requestId уже задан → редактируем существующую заявку, пишем в ту же
|
||||||
|
// партицию (editListId); иначе резолвится ТЕКУЩАЯ (см. resolveWriteListId —
|
||||||
|
// именно этот запрос проверяет порог элементов и запускает архивацию).
|
||||||
|
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
||||||
|
const { id, code } = await saveVacationRequest(
|
||||||
|
requestId,
|
||||||
|
rtId,
|
||||||
|
data,
|
||||||
|
toApprovers(matrix.rows),
|
||||||
|
status,
|
||||||
|
writeListId
|
||||||
|
);
|
||||||
setRequestId(id);
|
setRequestId(id);
|
||||||
setRequestStatus(status);
|
setRequestStatus(status);
|
||||||
if (code) {
|
if (code) {
|
||||||
@@ -183,13 +195,14 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
onSaved();
|
onSaved();
|
||||||
}
|
}
|
||||||
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
||||||
// Имена списков — той же партиции, что и сама заявка (editListId), не текущей:
|
// Имена списков — той же партиции, куда фактически записали (writeListId),
|
||||||
// поток ищет строку маршрута именно в НЕЙ.
|
// не обязательно текущей на момент рендера формы: поток ищет строку
|
||||||
|
// маршрута именно в НЕЙ.
|
||||||
if (status === REQUEST_STATUS.onApproval) {
|
if (status === REQUEST_STATUS.onApproval) {
|
||||||
await notifyActiveStep(context, approval, id, {
|
await notifyActiveStep(context, approval, id, {
|
||||||
requestTypeId: rtId,
|
requestTypeId: rtId,
|
||||||
dataListName: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID),
|
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID)
|
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,27 +1,15 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||||
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
|
||||||
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
import { ensureEmployee } from '../../shared/employee';
|
||||||
import { approvalListTitle, approvalListTitleForListId, dataListTitle } from '../../shared/listTitles';
|
|
||||||
import { FORM_ID, IFormData } from './model';
|
import { FORM_ID, IFormData } from './model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Экземпляр сервиса списка согласования этой формы.
|
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
|
||||||
*
|
* approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
|
||||||
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
|
* именем формы для читаемости вызывающего кода.
|
||||||
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
|
|
||||||
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
|
|
||||||
* архивной партиции имя приходится резолвить через реестр — если реестр не
|
|
||||||
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
|
|
||||||
* а не тихо откатится на текущую партицию.
|
|
||||||
*/
|
*/
|
||||||
export function vacationApprovalService(listId?: string): ApprovalListService {
|
export function vacationApprovalService(listId?: string): ApprovalListService {
|
||||||
return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID));
|
return approvalServiceFor(FORM_ID, listId);
|
||||||
}
|
|
||||||
|
|
||||||
/** email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка). */
|
|
||||||
async function ensureEmployee(login: string): Promise<{ Id: number; Title: string }> {
|
|
||||||
const full = login.indexOf('|') >= 0 ? login : `i:0#.f|membership|${login}`;
|
|
||||||
return getSP().web.ensureUser(full);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pad2(n: number): string {
|
function pad2(n: number): string {
|
||||||
@@ -55,11 +43,7 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISaveResult {
|
export type ISaveResult = ISaveItemResult;
|
||||||
id: number;
|
|
||||||
/** Задан только при создании новой заявки (существующий код не меняется). */
|
|
||||||
code?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохранить заявку на отпуск: содержимое + маршрут согласования.
|
* Сохранить заявку на отпуск: содержимое + маршрут согласования.
|
||||||
@@ -67,10 +51,13 @@ export interface ISaveResult {
|
|||||||
* Фазы исполнения у отпуска нет — writeRoute вызывается с пустым списком исполнителей.
|
* Фазы исполнения у отпуска нет — writeRoute вызывается с пустым списком исполнителей.
|
||||||
* Возвращает Id заявки (+ код при создании).
|
* Возвращает Id заявки (+ код при создании).
|
||||||
*
|
*
|
||||||
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadVacationRequest). Имеет смысл
|
* listId — партиция, в которую фактически пишем: при редактировании — партиция
|
||||||
* только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции —
|
* ОБНОВЛЯЕМОЙ заявки (см. loadVacationRequest), при создании — партиция,
|
||||||
* писать новые заявки в архив незачем и нельзя (approval-route переключает
|
* полученная от approval-route API непосредственно перед вызовом (см.
|
||||||
* текущую партицию сам, по своему порогу элементов).
|
* getCurrentListPartition в VacationForm.tsx) — именно этот вызов, а не кеш из
|
||||||
|
* onInit, проверяет порог элементов и запускает архивацию следующей партиции.
|
||||||
|
* Не задан (сбой запроса за текущей партицией) → откат на список по конвенции
|
||||||
|
* имён, как до появления партиционирования.
|
||||||
*/
|
*/
|
||||||
export async function saveVacationRequest(
|
export async function saveVacationRequest(
|
||||||
existingId: number | undefined,
|
existingId: number | undefined,
|
||||||
@@ -80,24 +67,10 @@ export async function saveVacationRequest(
|
|||||||
status: string,
|
status: string,
|
||||||
listId?: string
|
listId?: string
|
||||||
): Promise<ISaveResult> {
|
): Promise<ISaveResult> {
|
||||||
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);
|
||||||
|
|
||||||
let id: number;
|
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||||
let code: string | undefined;
|
await writeRoute(vacationApprovalService(listId), result.id, approvers, [], status);
|
||||||
if (existingId) {
|
return result;
|
||||||
await list.items.getById(existingId).update(item);
|
|
||||||
id = existingId;
|
|
||||||
} else {
|
|
||||||
const added = await list.items.add(item);
|
|
||||||
id = (added as { Id: number }).Id;
|
|
||||||
// Код известен только после создания элемента (нужен его Id).
|
|
||||||
code = buildRequestUniqueCode(requestTypeId, id, new Date());
|
|
||||||
await list.items.getById(id).update({ RequestUniqueCode: code });
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeRoute(vacationApprovalService(existingId ? listId : undefined), id, approvers, [], status);
|
|
||||||
return { id, code };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
|
resolveWriteListId,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
@@ -210,6 +211,14 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
|||||||
if (!rtId) {
|
if (!rtId) {
|
||||||
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||||
}
|
}
|
||||||
|
// requestId уже задан → редактируем существующую заявку, пишем в ту же
|
||||||
|
// партицию (editListId). Иначе — создаём новую, и партицию для записи
|
||||||
|
// спрашиваем у approval-route ПРЯМО СЕЙЧАС (а не берём из кеша onInit):
|
||||||
|
// это единственный запрос, который проверяет порог элементов текущего
|
||||||
|
// списка и ставит в очередь создание следующей партиции при достижении —
|
||||||
|
// без него архивация никогда не сработает. Сбой запроса не должен
|
||||||
|
// блокировать подачу заявки — откатываемся на список по конвенции имён.
|
||||||
|
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
||||||
const { id, code } = await saveWorkplaceRequest(
|
const { id, code } = await saveWorkplaceRequest(
|
||||||
requestId,
|
requestId,
|
||||||
rtId,
|
rtId,
|
||||||
@@ -218,7 +227,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
|||||||
toApprovers(executors.rows),
|
toApprovers(executors.rows),
|
||||||
status,
|
status,
|
||||||
sourceId,
|
sourceId,
|
||||||
editListId
|
writeListId
|
||||||
);
|
);
|
||||||
setRequestId(id);
|
setRequestId(id);
|
||||||
setRequestStatus(status);
|
setRequestStatus(status);
|
||||||
@@ -226,21 +235,22 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
|||||||
setRequestCode(code);
|
setRequestCode(code);
|
||||||
}
|
}
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
await uploadAttachments(editListId || dataListTitle(FORM_ID), id, files);
|
await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files);
|
||||||
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
||||||
setExistingAttachments(await getAttachments(editListId || dataListTitle(FORM_ID), id)); // показать добавленные
|
setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id)); // показать добавленные
|
||||||
}
|
}
|
||||||
if (onSaved) {
|
if (onSaved) {
|
||||||
onSaved();
|
onSaved();
|
||||||
}
|
}
|
||||||
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
||||||
// Имена списков — той же партиции, что и сама заявка (editListId), не текущей:
|
// Имена списков — той же партиции, куда фактически записали (writeListId),
|
||||||
// поток ищет строку маршрута именно в НЕЙ.
|
// не обязательно текущей на момент рендера формы: поток ищет строку
|
||||||
|
// маршрута именно в НЕЙ.
|
||||||
if (status === REQUEST_STATUS.onApproval) {
|
if (status === REQUEST_STATUS.onApproval) {
|
||||||
await notifyActiveStep(context, approval, id, {
|
await notifyActiveStep(context, approval, id, {
|
||||||
requestTypeId: rtId,
|
requestTypeId: rtId,
|
||||||
dataListName: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID),
|
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID),
|
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||||
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList';
|
||||||
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
|
||||||
import { approvalListTitle, approvalListTitleForListId, dataListTitle } from '../../shared/listTitles';
|
|
||||||
import { FORM_ID, IFormData } from './model';
|
import { FORM_ID, IFormData } from './model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Экземпляр сервиса списка согласования этой формы.
|
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
|
||||||
*
|
* approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
|
||||||
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
|
* именем формы для читаемости вызывающего кода.
|
||||||
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
|
|
||||||
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
|
|
||||||
* архивной партиции имя приходится резолвить через реестр — если реестр не
|
|
||||||
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
|
|
||||||
* а не тихо откатится на текущую партицию.
|
|
||||||
*/
|
*/
|
||||||
export function workplaceApprovalService(listId?: string): ApprovalListService {
|
export function workplaceApprovalService(listId?: string): ApprovalListService {
|
||||||
return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID));
|
return approvalServiceFor(FORM_ID, listId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,7 +23,7 @@ export async function updateReviewFields(
|
|||||||
fields: { Software: string; AccessRights: string },
|
fields: { Software: string; AccessRights: string },
|
||||||
listId?: string
|
listId?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
const list = resolveDataList(FORM_ID, listId);
|
||||||
await list.items.getById(id).update({
|
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,
|
||||||
@@ -62,11 +55,7 @@ function buildItem(data: IFormData, status: string, sourceHireId?: number): Reco
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISaveResult {
|
export type ISaveResult = ISaveItemResult;
|
||||||
id: number;
|
|
||||||
/** Задан только при создании новой заявки (существующий код не меняется). */
|
|
||||||
code?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохранить заявку «орг. рабочего места»: контент + маршрут (согласующие + исполнители).
|
* Сохранить заявку «орг. рабочего места»: контент + маршрут (согласующие + исполнители).
|
||||||
@@ -74,10 +63,13 @@ export interface ISaveResult {
|
|||||||
* Исполнители пишутся сразу — фаза исполнения стартует автоматически после согласования.
|
* Исполнители пишутся сразу — фаза исполнения стартует автоматически после согласования.
|
||||||
* sourceHireId — если заявка создана на основе «прием». Возвращает Id заявки (+ код при создании).
|
* sourceHireId — если заявка создана на основе «прием». Возвращает Id заявки (+ код при создании).
|
||||||
*
|
*
|
||||||
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadWorkplaceRequest). Имеет смысл
|
* listId — партиция, в которую фактически пишем: при редактировании — партиция
|
||||||
* только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции —
|
* ОБНОВЛЯЕМОЙ заявки (см. loadWorkplaceRequest), при создании — партиция,
|
||||||
* писать новые заявки в архив незачем и нельзя (approval-route переключает
|
* полученная от approval-route API непосредственно перед вызовом (см.
|
||||||
* текущую партицию сам, по своему порогу элементов).
|
* getCurrentListPartition в WorkplaceSetupForm.tsx) — именно этот вызов, а не
|
||||||
|
* кеш из onInit, проверяет порог элементов и запускает архивацию следующей
|
||||||
|
* партиции. Не задан (сбой запроса за текущей партицией) → откат на список по
|
||||||
|
* конвенции имён, как до появления партиционирования.
|
||||||
*/
|
*/
|
||||||
export async function saveWorkplaceRequest(
|
export async function saveWorkplaceRequest(
|
||||||
existingId: number | undefined,
|
existingId: number | undefined,
|
||||||
@@ -89,23 +81,9 @@ export async function saveWorkplaceRequest(
|
|||||||
sourceHireId?: number,
|
sourceHireId?: number,
|
||||||
listId?: string
|
listId?: string
|
||||||
): Promise<ISaveResult> {
|
): Promise<ISaveResult> {
|
||||||
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;
|
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||||
let code: string | undefined;
|
await writeRoute(workplaceApprovalService(listId), result.id, approvers, executors, status);
|
||||||
if (existingId) {
|
return result;
|
||||||
await list.items.getById(existingId).update(item);
|
|
||||||
id = existingId;
|
|
||||||
} else {
|
|
||||||
const added = await list.items.add(item);
|
|
||||||
id = (added as { Id: number }).Id;
|
|
||||||
// Код известен только после создания элемента (нужен его Id).
|
|
||||||
code = buildRequestUniqueCode(requestTypeId, id, new Date());
|
|
||||||
await list.items.getById(id).update({ RequestUniqueCode: code });
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeRoute(workplaceApprovalService(existingId ? listId : undefined), id, approvers, executors, status);
|
|
||||||
return { id, code };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ define([], function() {
|
|||||||
return {
|
return {
|
||||||
"PropertyPaneDescription": "Description",
|
"PropertyPaneDescription": "Description",
|
||||||
"BasicGroupName": "Group Name",
|
"BasicGroupName": "Group Name",
|
||||||
"DescriptionFieldLabel": "Description Field",
|
"DebugUserFieldLabel": "Отладка: работать от имени сотрудника",
|
||||||
"DebugUserEmailFieldLabel": "Отладка: работать от имени сотрудника",
|
"DebugUserFieldDescription": "Только для отладки маршрутов. Введите фамилию и имя сотрудника (можно в обратном порядке, отчество не обязательно) — портал найдёт его e-mail сам, найденное отобразится над полем. Можно ввести e-mail напрямую. По этому e-mail планируется маршрут, отбираются «свои» заявки и очередь согласования. Прав это не даёт — запись в списки всё равно идёт под вашей учёткой. На боевой странице оставьте пустым.",
|
||||||
"DebugUserEmailFieldDescription": "Только для отладки маршрутов. Портал будет считать текущим пользователем указанный e-mail: по нему планируется маршрут, отбираются «свои» заявки и очередь согласования. Прав это не даёт — запись в списки всё равно идёт под вашей учёткой. На боевой странице оставьте пустым.",
|
|
||||||
"AppLocalEnvironmentSharePoint": "The app is running on your local environment as SharePoint web part",
|
"AppLocalEnvironmentSharePoint": "The app is running on your local environment as SharePoint web part",
|
||||||
"AppLocalEnvironmentTeams": "The app is running on your local environment as Microsoft Teams app",
|
"AppLocalEnvironmentTeams": "The app is running on your local environment as Microsoft Teams app",
|
||||||
"AppLocalEnvironmentOffice": "The app is running on your local environment in office.com",
|
"AppLocalEnvironmentOffice": "The app is running on your local environment in office.com",
|
||||||
|
|||||||
+2
-3
@@ -1,9 +1,8 @@
|
|||||||
declare interface IRequestPortalWebPartStrings {
|
declare interface IRequestPortalWebPartStrings {
|
||||||
PropertyPaneDescription: string;
|
PropertyPaneDescription: string;
|
||||||
BasicGroupName: string;
|
BasicGroupName: string;
|
||||||
DescriptionFieldLabel: string;
|
DebugUserFieldLabel: string;
|
||||||
DebugUserEmailFieldLabel: string;
|
DebugUserFieldDescription: string;
|
||||||
DebugUserEmailFieldDescription: string;
|
|
||||||
AppLocalEnvironmentSharePoint: string;
|
AppLocalEnvironmentSharePoint: string;
|
||||||
AppLocalEnvironmentTeams: string;
|
AppLocalEnvironmentTeams: string;
|
||||||
AppLocalEnvironmentOffice: string;
|
AppLocalEnvironmentOffice: string;
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { resolveDebugUser } from '../shared/impersonation';
|
||||||
|
|
||||||
|
export interface IDebugUserFieldProps {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
context: WebPartContext;
|
||||||
|
initialQuery: string;
|
||||||
|
/** Зовётся при каждом изменении: query — как введено, email/displayName — итог поиска ('' если не найдено). */
|
||||||
|
onResolved: (query: string, email: string, displayName: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Status = 'idle' | 'loading' | 'found' | 'notfound' | 'email';
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 400;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поле свойств веб-части: ввод «Фамилия Имя [Отчество]» (в любом порядке) или
|
||||||
|
* e-mail напрямую. Сам резолвит e-mail через SharePoint people search — см.
|
||||||
|
* shared/impersonation.ts (resolveDebugUser) — и показывает найденное тут же,
|
||||||
|
* чтобы не гадать, кого именно подставили.
|
||||||
|
*/
|
||||||
|
export const DebugUserField: React.FC<IDebugUserFieldProps> = ({ label, description, context, initialQuery, onResolved }) => {
|
||||||
|
const [query, setQuery] = React.useState(initialQuery || '');
|
||||||
|
const [status, setStatus] = React.useState<Status>('idle');
|
||||||
|
const [found, setFound] = React.useState<{ email: string; displayName: string } | undefined>(undefined);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const text = query.trim();
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
setStatus('idle');
|
||||||
|
setFound(undefined);
|
||||||
|
onResolved('', '', '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// E-mail введён напрямую — используем его как есть, но всё равно ищем ФИО
|
||||||
|
// (для красной полосы в интерфейсе и для проверки, что такой сотрудник есть).
|
||||||
|
const isEmail = text.indexOf('@') >= 0;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setStatus(isEmail ? 'email' : 'loading');
|
||||||
|
if (isEmail) setFound(undefined);
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
resolveDebugUser(context, text)
|
||||||
|
.then((hit) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (isEmail) {
|
||||||
|
setFound(hit);
|
||||||
|
onResolved(text, text, hit ? hit.displayName : '');
|
||||||
|
} else if (hit) {
|
||||||
|
setStatus('found');
|
||||||
|
setFound(hit);
|
||||||
|
onResolved(text, hit.email, hit.displayName);
|
||||||
|
} else {
|
||||||
|
setStatus('notfound');
|
||||||
|
setFound(undefined);
|
||||||
|
onResolved(text, '', '');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (isEmail) {
|
||||||
|
onResolved(text, text, '');
|
||||||
|
} else {
|
||||||
|
setStatus('notfound');
|
||||||
|
setFound(undefined);
|
||||||
|
onResolved(text, '', '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 4 }}>{label}</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Иванов Иван Иванович, Иван Иванов или e-mail"
|
||||||
|
style={{ width: '100%', boxSizing: 'border-box', padding: '5px 8px', fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 4, fontSize: 12, lineHeight: 1.4, color: '#a4262c', minHeight: 16 }}>
|
||||||
|
{status === 'loading' && 'Ищем сотрудника…'}
|
||||||
|
{status === 'found' && found && `Найдено: ${found.displayName} — ${found.email}`}
|
||||||
|
{status === 'notfound' && 'Сотрудник не найден — подмена работать не будет'}
|
||||||
|
{status === 'email' &&
|
||||||
|
(found
|
||||||
|
? `Используется как e-mail напрямую: ${query.trim()} (${found.displayName})`
|
||||||
|
: `Используется как e-mail напрямую: ${query.trim()}`)}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 6, fontSize: 12, lineHeight: 1.4, color: '#605e5c' }}>{description}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as ReactDom from 'react-dom';
|
||||||
|
import {
|
||||||
|
IPropertyPaneField,
|
||||||
|
PropertyPaneFieldType,
|
||||||
|
IPropertyPaneCustomFieldProps
|
||||||
|
} from '@microsoft/sp-property-pane';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { DebugUserField } from './DebugUserField';
|
||||||
|
|
||||||
|
export interface IPropertyPaneDebugUserProps {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
context: WebPartContext;
|
||||||
|
initialQuery: string;
|
||||||
|
onResolved: (query: string, email: string, displayName: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PropertyPaneDebugUserBuilder
|
||||||
|
implements IPropertyPaneField<IPropertyPaneCustomFieldProps & IPropertyPaneDebugUserProps> {
|
||||||
|
public type: PropertyPaneFieldType = PropertyPaneFieldType.Custom;
|
||||||
|
public targetProperty: string;
|
||||||
|
public properties: IPropertyPaneCustomFieldProps & IPropertyPaneDebugUserProps;
|
||||||
|
|
||||||
|
constructor(targetProperty: string, properties: IPropertyPaneDebugUserProps) {
|
||||||
|
this.targetProperty = targetProperty;
|
||||||
|
this.properties = {
|
||||||
|
key: targetProperty,
|
||||||
|
onRender: this.onRender.bind(this),
|
||||||
|
onDispose: this.onDispose.bind(this),
|
||||||
|
...properties
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private onRender(elem: HTMLElement): void {
|
||||||
|
ReactDom.render(
|
||||||
|
<DebugUserField
|
||||||
|
label={this.properties.label}
|
||||||
|
description={this.properties.description}
|
||||||
|
context={this.properties.context}
|
||||||
|
initialQuery={this.properties.initialQuery}
|
||||||
|
onResolved={this.properties.onResolved}
|
||||||
|
/>,
|
||||||
|
elem
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onDispose(elem: HTMLElement): void {
|
||||||
|
ReactDom.unmountComponentAtNode(elem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Кастомное поле property pane: ФИО/e-mail сотрудника с резолвом в e-mail на лету. */
|
||||||
|
export function PropertyPaneDebugUser(
|
||||||
|
targetProperty: string,
|
||||||
|
properties: IPropertyPaneDebugUserProps
|
||||||
|
): IPropertyPaneField<IPropertyPaneCustomFieldProps & IPropertyPaneDebugUserProps> {
|
||||||
|
return new PropertyPaneDebugUserBuilder(targetProperty, properties);
|
||||||
|
}
|
||||||
@@ -129,6 +129,7 @@ export class ApprovalListService {
|
|||||||
toWrite.forEach(({ a, order }, i) => {
|
toWrite.forEach(({ a, order }, i) => {
|
||||||
// void: промис от add() на batched-инстансе нарочно не ждём поштучно —
|
// void: промис от add() на batched-инстансе нарочно не ждём поштучно —
|
||||||
// он разрешится вместе со всеми остальными после execute() ниже.
|
// он разрешится вместе со всеми остальными после execute() ниже.
|
||||||
|
// eslint-disable-next-line no-void -- без void это «висячий» промис, а no-floating-promises здесь уровня error
|
||||||
void batchedList.items.add({
|
void batchedList.items.add({
|
||||||
Title: a.position || a.email,
|
Title: a.position || a.email,
|
||||||
[`${FIELDS.approver}Id`]: approverIds[i],
|
[`${FIELDS.approver}Id`]: approverIds[i],
|
||||||
@@ -380,6 +381,7 @@ export class ApprovalListService {
|
|||||||
const batchedItems = batchedSP.web.lists.getByTitle(this.listTitle).items;
|
const batchedItems = batchedSP.web.lists.getByTitle(this.listTitle).items;
|
||||||
rows.forEach((row) => {
|
rows.forEach((row) => {
|
||||||
// void — по той же причине, что и в createSteps выше.
|
// void — по той же причине, что и в createSteps выше.
|
||||||
|
// eslint-disable-next-line no-void -- без void это «висячий» промис, а no-floating-promises здесь уровня error
|
||||||
void batchedItems.getById(row.id).delete();
|
void batchedItems.getById(row.id).delete();
|
||||||
});
|
});
|
||||||
await execute();
|
await execute();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { WebPartContext } from '@microsoft/sp-webpart-base';
|
|||||||
import { APPROVAL_API_CLIENT_ID, getApprovalApiBaseUrl } from '../config';
|
import { APPROVAL_API_CLIENT_ID, getApprovalApiBaseUrl } from '../config';
|
||||||
import {
|
import {
|
||||||
ApprovalStatus,
|
ApprovalStatus,
|
||||||
|
ICurrentListPartition,
|
||||||
IApiResponse,
|
IApiResponse,
|
||||||
IRequestType,
|
IRequestType,
|
||||||
IRequestTypeListSeries,
|
IRequestTypeListSeries,
|
||||||
@@ -54,6 +55,30 @@ export async function getListPartitions(context: WebPartContext): Promise<IReque
|
|||||||
return body.data ?? [];
|
return body.data ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/request-types/{id}/lists/current — партиция, в которую нужно писать
|
||||||
|
* НОВУЮ заявку этого типа. Вызывать перед КАЖДЫМ созданием, не кешировать: этот
|
||||||
|
* запрос заодно проверяет порог элементов на бэкенде и ставит в очередь
|
||||||
|
* создание следующей партиции при достижении — без него архивация никогда не
|
||||||
|
* запустится, сколько бы заявок ни создали (bulk-эндпоинт lists, который
|
||||||
|
* портал и так грузит при onInit, только читает реестр и порог не проверяет).
|
||||||
|
*/
|
||||||
|
export async function getCurrentListPartition(
|
||||||
|
context: WebPartContext,
|
||||||
|
requestTypeId: number
|
||||||
|
): Promise<ICurrentListPartition> {
|
||||||
|
const c = await client(context);
|
||||||
|
const response: HttpClientResponse = await c.get(
|
||||||
|
`${getApprovalApiBaseUrl()}/api/request-types/${requestTypeId}/lists/current`,
|
||||||
|
AadHttpClient.configurations.v1
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`request-types/${requestTypeId}/lists/current: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const body: IApiResponse<ICurrentListPartition> = await response.json();
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
// Кеш на уровне МОДУЛЯ (не useRef/useState) — список типов заявок админ меняет
|
// Кеш на уровне МОДУЛЯ (не useRef/useState) — список типов заявок админ меняет
|
||||||
// редко, а без кеша каждая форма и каждый переход в режим согласующего дёргали
|
// редко, а без кеша каждая форма и каждый переход в режим согласующего дёргали
|
||||||
// бы /api/request-types заново. Живёт до перезагрузки страницы — тот же принцип,
|
// бы /api/request-types заново. Живёт до перезагрузки страницы — тот же принцип,
|
||||||
@@ -107,8 +132,10 @@ export async function resolveRoute(
|
|||||||
requestTypeId: number,
|
requestTypeId: number,
|
||||||
userEmail: string,
|
userEmail: string,
|
||||||
status: ApprovalStatus = 'Approval',
|
status: ApprovalStatus = 'Approval',
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- null отправляется в теле запроса как «не задано», undefined в JSON.stringify пропал бы вовсе
|
||||||
departmentId: string | null = null,
|
departmentId: string | null = null,
|
||||||
fieldValues: IResolveFieldValue[] = [],
|
fieldValues: IResolveFieldValue[] = [],
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- см. departmentId
|
||||||
projectId: string | null = null
|
projectId: string | null = null
|
||||||
): Promise<IResolvedRoute> {
|
): Promise<IResolvedRoute> {
|
||||||
const c = await client(context);
|
const c = await client(context);
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { RequestTypeId } from '../../models/types';
|
||||||
|
import { approvalListTitle, approvalListTitleForListId } from '../listTitles';
|
||||||
|
import { ApprovalListService } from './approvalList.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экземпляр сервиса списка согласования формы.
|
||||||
|
*
|
||||||
|
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
|
||||||
|
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
|
||||||
|
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
|
||||||
|
* архивной партиции имя приходится резолвить через реестр — если реестр не
|
||||||
|
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
|
||||||
|
* а не тихо откатится на текущую партицию.
|
||||||
|
*
|
||||||
|
* Раньше каждая форма объявляла свою «xApprovalService» с телом-копией этой
|
||||||
|
* функции (8 одинаковых реализаций) — оставлены только тонкие обёртки под
|
||||||
|
* именем формы (см. submit.ts каждой формы), чтобы вызывающий код не терял
|
||||||
|
* читаемость («vacationApprovalService» вместо безликого параметра formId).
|
||||||
|
*/
|
||||||
|
export function approvalServiceFor(formId: RequestTypeId, listId?: string): ApprovalListService {
|
||||||
|
return new ApprovalListService(
|
||||||
|
listId ? approvalListTitleForListId(formId, listId) : approvalListTitle(formId)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,8 +15,10 @@ export async function getPlannedMatrix(
|
|||||||
userEmail: string,
|
userEmail: string,
|
||||||
approval: ApprovalListService,
|
approval: ApprovalListService,
|
||||||
status: ApprovalStatus = 'Approval',
|
status: ApprovalStatus = 'Approval',
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- транзитом в resolveRoute(), см. её departmentId
|
||||||
departmentId?: string | null,
|
departmentId?: string | null,
|
||||||
fieldValues?: IResolveFieldValue[],
|
fieldValues?: IResolveFieldValue[],
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- см. departmentId
|
||||||
projectId?: string | null
|
projectId?: string | null
|
||||||
): Promise<IMatrixRow[]> {
|
): Promise<IMatrixRow[]> {
|
||||||
if (!userEmail) return [];
|
if (!userEmail) return [];
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type { IApprovalRow } from './models/IApprovalRow';
|
|||||||
export type {
|
export type {
|
||||||
ApprovalStatus,
|
ApprovalStatus,
|
||||||
IListPartition,
|
IListPartition,
|
||||||
|
ICurrentListPartition,
|
||||||
IRequestType,
|
IRequestType,
|
||||||
IRequestTypeListSeries,
|
IRequestTypeListSeries,
|
||||||
IResolvedRoute,
|
IResolvedRoute,
|
||||||
@@ -21,7 +22,13 @@ export type {
|
|||||||
IResolveFieldValue,
|
IResolveFieldValue,
|
||||||
} from './models/IApprovalRouteApi';
|
} from './models/IApprovalRouteApi';
|
||||||
|
|
||||||
export { getRequestTypes, getRequestTypeIdByPageUrl, getListPartitions, resolveRoute } from './approvalRoute.service';
|
export {
|
||||||
|
getRequestTypes,
|
||||||
|
getRequestTypeIdByPageUrl,
|
||||||
|
getListPartitions,
|
||||||
|
getCurrentListPartition,
|
||||||
|
resolveRoute
|
||||||
|
} from './approvalRoute.service';
|
||||||
export {
|
export {
|
||||||
initListPartitions,
|
initListPartitions,
|
||||||
listPartitionsLoaded,
|
listPartitionsLoaded,
|
||||||
@@ -30,11 +37,14 @@ export {
|
|||||||
currentPartitionForPageUrl,
|
currentPartitionForPageUrl,
|
||||||
partitionForPageUrlAndListId,
|
partitionForPageUrlAndListId,
|
||||||
isConfirmedStaleListId,
|
isConfirmedStaleListId,
|
||||||
|
registerCurrentPartition,
|
||||||
} from './listPartitions';
|
} from './listPartitions';
|
||||||
|
export { resolveWriteListId } from './writeListId';
|
||||||
export { useRequestTypeId } from './useRequestTypeId';
|
export { useRequestTypeId } from './useRequestTypeId';
|
||||||
export type { IRequestTypeIdState } from './useRequestTypeId';
|
export type { IRequestTypeIdState } from './useRequestTypeId';
|
||||||
export { ApprovalListService } from './approvalList.service';
|
export { ApprovalListService } from './approvalList.service';
|
||||||
export type { IApproverToWrite } from './approvalList.service';
|
export type { IApproverToWrite } from './approvalList.service';
|
||||||
|
export { approvalServiceFor } from './approvalServiceFactory';
|
||||||
export { ApproverService } from './approverDecision.service';
|
export { ApproverService } from './approverDecision.service';
|
||||||
export type { ApproverDecision, IPendingRow } from './approverDecision.service';
|
export type { ApproverDecision, IPendingRow } from './approverDecision.service';
|
||||||
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
|
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { getListPartitions, getRequestTypes } from './approvalRoute.service';
|
import { getListPartitions, getRequestTypes } from './approvalRoute.service';
|
||||||
import { IListPartition } from './models/IApprovalRouteApi';
|
import { ICurrentListPartition, IListPartition } from './models/IApprovalRouteApi';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Реестр партиций списков — единственное место, которое знает, куда ПИСАТЬ и где
|
* Реестр партиций списков — единственное место, которое знает, куда ПИСАТЬ и где
|
||||||
@@ -90,6 +90,45 @@ export function currentPartitionForPageUrl(pageUrl: string): IListPartition | un
|
|||||||
return id === undefined ? undefined : currentPartitionForTypeId(id);
|
return id === undefined ? undefined : currentPartitionForTypeId(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вписывает в кеш партицию, которую approval-route ТОЛЬКО ЧТО вернул как текущую
|
||||||
|
* (см. getCurrentListPartition) — вызывать сразу после успешного ответа, ДО того
|
||||||
|
* как эта партиция понадобится дальше в том же сохранении (approvalListTitleForListId
|
||||||
|
* и т.п.), а не только при следующей перезагрузке страницы.
|
||||||
|
*
|
||||||
|
* Без этого партиция, созданная провижинингом ПОСЛЕ onInit (порог элементов
|
||||||
|
* достигнут прямо во время этого запроса), была бы неизвестна isConfirmedStaleListId,
|
||||||
|
* и попытка сохранить в неё же заявку — записав данные в новый список — падала бы
|
||||||
|
* следующим шагом с «список не найден в реестре партиций», хотя ссылка совершенно
|
||||||
|
* свежая и рабочая (см. форму пользователя: данные создались, ошибка была только
|
||||||
|
* в определении имени списка согласования).
|
||||||
|
*
|
||||||
|
* Тип, о котором реестр вообще ничего не знает (initListPartitions не ответил при
|
||||||
|
* старте — typeIdByPageUrl пуст для этого pageUrl), не трогаем: мержить партицию в
|
||||||
|
* несуществующую серию нечем, а откат на конвенцию имён и так работает без реестра.
|
||||||
|
*/
|
||||||
|
export function registerCurrentPartition(pageUrl: string, partition: ICurrentListPartition): void {
|
||||||
|
const id = typeIdByPageUrl.get(pageUrl);
|
||||||
|
if (id === undefined) return;
|
||||||
|
|
||||||
|
const lower = partition.listId.toLowerCase();
|
||||||
|
const merged: IListPartition[] = partitionsForTypeId(id)
|
||||||
|
.filter((p) => p.listId.toLowerCase() !== lower)
|
||||||
|
.map((p) => ({ ...p, isCurrent: false }));
|
||||||
|
|
||||||
|
merged.push({
|
||||||
|
number: partition.number,
|
||||||
|
siteUrl: partition.siteUrl,
|
||||||
|
listId: partition.listId,
|
||||||
|
title: partition.title,
|
||||||
|
isCurrent: true,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
merged.sort((a, b) => a.number - b.number);
|
||||||
|
|
||||||
|
partitionsByTypeId.set(id, merged);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Конкретная партиция по её listId — нужна, чтобы открыть/сохранить АРХИВНУЮ
|
* Конкретная партиция по её listId — нужна, чтобы открыть/сохранить АРХИВНУЮ
|
||||||
* заявку: тогда известен не номер, а сам listId (пришёл из строки поиска, см.
|
* заявку: тогда известен не номер, а сам listId (пришёл из строки поиска, см.
|
||||||
|
|||||||
@@ -50,6 +50,23 @@ export interface IRequestTypeListSeries {
|
|||||||
partitions: IListPartition[];
|
partitions: IListPartition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/request-types/{id}/lists/current → { data: ICurrentListPartition }
|
||||||
|
*
|
||||||
|
* Партиция, в которую нужно писать НОВУЮ заявку. Не чистый GET на бэкенде —
|
||||||
|
* этот же запрос проверяет порог элементов и при необходимости ставит в
|
||||||
|
* очередь создание следующей партиции, поэтому его нужно звать перед КАЖДЫМ
|
||||||
|
* созданием заявки, а не читать из кеша (см. shared/approval/listPartitions.ts,
|
||||||
|
* который для этого не подходит — грузится один раз при onInit).
|
||||||
|
*/
|
||||||
|
export interface ICurrentListPartition {
|
||||||
|
requestTypeId: number;
|
||||||
|
number: number;
|
||||||
|
siteUrl: string;
|
||||||
|
listId: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Условие видимости шага маршрута (настраивается в админке маршрутов —
|
// Условие видимости шага маршрута (настраивается в админке маршрутов —
|
||||||
// «Показывать шаг только при выполнении условия», сравнение «Поле списка»/
|
// «Показывать шаг только при выполнении условия», сравнение «Поле списка»/
|
||||||
// «Оператор»/«Значение»). fieldName — внутреннее имя колонки списка (напр.
|
// «Оператор»/«Значение»). fieldName — внутреннее имя колонки списка (напр.
|
||||||
@@ -71,11 +88,14 @@ export interface IResolvedRoute {
|
|||||||
export interface IResolvedRouteStep {
|
export interface IResolvedRouteStep {
|
||||||
id: number;
|
id: number;
|
||||||
orderNo: number;
|
orderNo: number;
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
|
||||||
executorEmail: string | null;
|
executorEmail: string | null;
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- см. executorEmail
|
||||||
position: string | null;
|
position: string | null;
|
||||||
allowReassignToOtherApprovers: boolean;
|
allowReassignToOtherApprovers: boolean;
|
||||||
// Предупреждение маршрута по этому шагу (например, роль не разрешилась в
|
// Предупреждение маршрута по этому шагу (например, роль не разрешилась в
|
||||||
// конкретного человека) — приходит от API, показываем автору при желании.
|
// конкретного человека) — приходит от API, показываем автору при желании.
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- см. executorEmail
|
||||||
warning: string | null;
|
warning: string | null;
|
||||||
// SLA шага в часах и признак опроса причины просрочки. Наследование
|
// SLA шага в часах и признак опроса причины просрочки. Наследование
|
||||||
// «шаг перекрывает тип заявки» бэкенд уже применил — здесь итоговое значение,
|
// «шаг перекрывает тип заявки» бэкенд уже применил — здесь итоговое значение,
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
// подсвечивается пол-таблицы и непонятно, что именно переехало. Запоминаем
|
// подсвечивается пол-таблицы и непонятно, что именно переехало. Запоминаем
|
||||||
// ключи блока ДО перестановки: после неё индексы уже другие.
|
// ключи блока ДО перестановки: после неё индексы уже другие.
|
||||||
const [activeKeys, setActiveKeys] = React.useState<string[]>([]);
|
const [activeKeys, setActiveKeys] = React.useState<string[]>([]);
|
||||||
const registerRow = useFlipRows(activeKeys, rowKeys);
|
// Обёртка таблицы: её высоту useFlipRows анимирует вместе с переездом строк,
|
||||||
|
// чтобы пикер и следующая карточка не подпрыгивали при удалении шага.
|
||||||
|
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const registerRow = useFlipRows(activeKeys, rowKeys, scrollRef);
|
||||||
|
|
||||||
// Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку
|
// Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку
|
||||||
// «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже.
|
// «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже.
|
||||||
@@ -110,7 +113,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
<div className={styles.matrixNote}>{emptyText}</div>
|
<div className={styles.matrixNote}>{emptyText}</div>
|
||||||
)}
|
)}
|
||||||
{!waiting && !loading && !error && rows.length > 0 && (
|
{!waiting && !loading && !error && rows.length > 0 && (
|
||||||
<div className={styles.matrixScroll}>
|
<div className={styles.matrixScroll} ref={scrollRef}>
|
||||||
<table className={styles.matrix}>
|
<table className={styles.matrix}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -95,6 +95,19 @@
|
|||||||
// сплющивания колонок. На телефоне таблица становится карточками (медиазапрос ниже).
|
// сплющивания колонок. На телефоне таблица становится карточками (медиазапрос ниже).
|
||||||
.matrixScroll {
|
.matrixScroll {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
// overflow-y ОБЯЗАН быть задан явно. По спецификации, если одна ось получает
|
||||||
|
// прокрутку, вторая с `visible` вычисляется в `auto` — то есть один
|
||||||
|
// overflow-x: auto включал прокрутку и по вертикали тоже. А анимация
|
||||||
|
// перестановки (см. ui/useFlipRows) стартует с translateY на высоту строки:
|
||||||
|
// при удалении строки нижние сначала отрисованы СМЕЩЁННЫМИ ВНИЗ, вылезают за
|
||||||
|
// нижнюю границу контейнера — на кадр появляется вертикальная полоса
|
||||||
|
// прокрутки, из-за неё контейнер сужается, текст в колонках перевёрстывается,
|
||||||
|
// строки съезжают, а через 380 мс всё это откатывается назад. Отсюда и
|
||||||
|
// дрожание интерфейса на каждое удаление позиции.
|
||||||
|
//
|
||||||
|
// hidden, а не clip: `clip` не создаёт scroll container, и тогда вертикальный
|
||||||
|
// вылет анимации утёк бы наружу, прокручивая уже саму страницу.
|
||||||
|
overflow-y: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ import * as React from 'react';
|
|||||||
// центра разрывало блок визуально. Плюс строка становилась шире контейнера
|
// центра разрывало блок визуально. Плюс строка становилась шире контейнера
|
||||||
// и .matrixScroll (overflow-x: auto) на время анимации показывал полосу
|
// и .matrixScroll (overflow-x: auto) на время анимации показывал полосу
|
||||||
// горизонтальной прокрутки.
|
// горизонтальной прокрутки.
|
||||||
// • пружинистый перелёт (cubic-bezier с >1): при overflow-x: auto вторая ось
|
// • пружинистый перелёт (cubic-bezier с >1): вылет за край .matrixScroll
|
||||||
// тоже становится auto, и вылет за край таблицы строку бы обрезало.
|
// обрезается (там overflow-y: hidden — см. approval.module.scss), так что
|
||||||
|
// перелёт был бы виден только как срезанная строка.
|
||||||
// • подсветка ВСЕХ переехавших строк: при обмене двух шагов двигаются оба, и
|
// • подсветка ВСЕХ переехавших строк: при обмене двух шагов двигаются оба, и
|
||||||
// жёлтым заливало пол-таблицы. Подсвечиваем только тот блок, который двигал
|
// жёлтым заливало пол-таблицы. Подсвечиваем только тот блок, который двигал
|
||||||
// пользователь (activeKeys).
|
// пользователь (activeKeys).
|
||||||
@@ -108,10 +109,16 @@ function measureTops(nodes: Map<string, HTMLTableRowElement>): Map<string, numbe
|
|||||||
* @param rowKeys ключи строк в ТЕКУЩЕМ порядке — единственный сигнал, по
|
* @param rowKeys ключи строк в ТЕКУЩЕМ порядке — единственный сигнал, по
|
||||||
* которому решаем, действительно ли что-то переставили (см. ниже, почему
|
* которому решаем, действительно ли что-то переставили (см. ниже, почему
|
||||||
* геометрии для этого недостаточно).
|
* геометрии для этого недостаточно).
|
||||||
|
* @param containerRef прокручиваемая обёртка таблицы (.matrixScroll). Её высоту
|
||||||
|
* анимируем в такт переезду строк: без этого React схлопывает высоту таблицы
|
||||||
|
* мгновенно, а строки едут 380 мс — и всё, что стоит НИЖЕ маршрута (пикер
|
||||||
|
* «Добавить согласующего», следующая карточка), рывком подпрыгивает на высоту
|
||||||
|
* удалённой строки. Необязателен: без него анимируются только строки.
|
||||||
*/
|
*/
|
||||||
export function useFlipRows(
|
export function useFlipRows(
|
||||||
activeKeys: string[],
|
activeKeys: string[],
|
||||||
rowKeys: string[]
|
rowKeys: string[],
|
||||||
|
containerRef?: React.RefObject<HTMLElement>
|
||||||
// eslint-disable-next-line @rushstack/no-new-null -- null здесь диктует React: ref-колбэк получает именно null при отвязке узла
|
// eslint-disable-next-line @rushstack/no-new-null -- null здесь диктует React: ref-колбэк получает именно null при отвязке узла
|
||||||
): (key: string) => (el: HTMLTableRowElement | null) => void {
|
): (key: string) => (el: HTMLTableRowElement | null) => void {
|
||||||
const nodes = React.useRef(new Map<string, HTMLTableRowElement>());
|
const nodes = React.useRef(new Map<string, HTMLTableRowElement>());
|
||||||
@@ -121,6 +128,10 @@ export function useFlipRows(
|
|||||||
const prevTops = React.useRef(new Map<string, number>());
|
const prevTops = React.useRef(new Map<string, number>());
|
||||||
// Порядок строк на прошлый коммит — см. проверку orderChanged ниже.
|
// Порядок строк на прошлый коммит — см. проверку orderChanged ниже.
|
||||||
const prevRowKeys = React.useRef<string[]>([]);
|
const prevRowKeys = React.useRef<string[]>([]);
|
||||||
|
// Высота обёртки на прошлый коммит. Нужна именно запомненная: layout-эффект
|
||||||
|
// работает УЖЕ ПОСЛЕ коммита, и к этому моменту высота на экране новая —
|
||||||
|
// «откуда ехать» из текущей раскладки не узнать.
|
||||||
|
const prevHeight = React.useRef<number | undefined>(undefined);
|
||||||
// Свои анимации держим сами, чтобы отменять ИМЕННО их: el.getAnimations()
|
// Свои анимации держим сами, чтобы отменять ИМЕННО их: el.getAnimations()
|
||||||
// вернул бы заодно чужие (CSS-переходы и т.п.).
|
// вернул бы заодно чужие (CSS-переходы и т.п.).
|
||||||
const running = React.useRef<Animation[]>([]);
|
const running = React.useRef<Animation[]>([]);
|
||||||
@@ -156,10 +167,13 @@ export function useFlipRows(
|
|||||||
rowKeys.length !== prevRowKeys.current.length || rowKeys.some((k, i) => k !== prevRowKeys.current[i]);
|
rowKeys.length !== prevRowKeys.current.length || rowKeys.some((k, i) => k !== prevRowKeys.current[i]);
|
||||||
prevRowKeys.current = rowKeys;
|
prevRowKeys.current = rowKeys;
|
||||||
|
|
||||||
|
const container = containerRef ? containerRef.current : null;
|
||||||
|
|
||||||
if (!orderChanged) {
|
if (!orderChanged) {
|
||||||
// Порядок тот же — просто обновляем базовую геометрию для следующего
|
// Порядок тот же — просто обновляем базовую геометрию для следующего
|
||||||
// ДЕЙСТВИТЕЛЬНОГО сравнения, без единой анимации на этом проходе.
|
// ДЕЙСТВИТЕЛЬНОГО сравнения, без единой анимации на этом проходе.
|
||||||
prevTops.current = measureTops(nodes.current);
|
prevTops.current = measureTops(nodes.current);
|
||||||
|
prevHeight.current = container ? container.getBoundingClientRect().height : undefined;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +181,10 @@ export function useFlipRows(
|
|||||||
// она в этот момент ещё летит, и новая анимация обязана стартовать от её
|
// она в этот момент ещё летит, и новая анимация обязана стартовать от её
|
||||||
// текущего положения на экране, иначе будет рывок.
|
// текущего положения на экране, иначе будет рывок.
|
||||||
const visualTops = measureTops(nodes.current);
|
const visualTops = measureTops(nodes.current);
|
||||||
|
// То же и для высоты обёртки: если анимация высоты ещё идёт, здесь мы
|
||||||
|
// получим её промежуточное значение (после cancel ниже высота схлопнется к
|
||||||
|
// натуральной, и «откуда ехать» было бы уже не восстановить).
|
||||||
|
const visualHeight = container ? container.getBoundingClientRect().height : undefined;
|
||||||
|
|
||||||
running.current.forEach((a) => a.cancel());
|
running.current.forEach((a) => a.cancel());
|
||||||
running.current = [];
|
running.current = [];
|
||||||
@@ -195,7 +213,38 @@ export function useFlipRows(
|
|||||||
|
|
||||||
prevTops.current = current;
|
prevTops.current = current;
|
||||||
|
|
||||||
if (prefersReducedMotion() || (moved.length === 0 && entered.length === 0)) return;
|
// Высота: «куда» — натуральная (анимации уже отменены). «Откуда» — то, что
|
||||||
|
// было видно: промежуточное значение недоигранной анимации, если она шла
|
||||||
|
// (visualHeight разошёлся с натуральным), иначе высота прошлого коммита.
|
||||||
|
const heightTo = container ? container.getBoundingClientRect().height : undefined;
|
||||||
|
const heightFrom =
|
||||||
|
visualHeight !== undefined && heightTo !== undefined && Math.abs(visualHeight - heightTo) >= MIN_SHIFT_PX
|
||||||
|
? visualHeight
|
||||||
|
: prevHeight.current;
|
||||||
|
prevHeight.current = heightTo;
|
||||||
|
const heightChanged =
|
||||||
|
!!container &&
|
||||||
|
heightFrom !== undefined &&
|
||||||
|
heightTo !== undefined &&
|
||||||
|
Math.abs(heightFrom - heightTo) >= MIN_SHIFT_PX;
|
||||||
|
|
||||||
|
// heightChanged в условии — отдельным слагаемым: при удалении ПОСЛЕДНЕЙ
|
||||||
|
// строки ехать некому (moved и entered пусты), но высота всё равно
|
||||||
|
// меняется, и без анимации нижняя карточка снова подпрыгнула бы.
|
||||||
|
if (prefersReducedMotion() || (moved.length === 0 && entered.length === 0 && !heightChanged)) return;
|
||||||
|
|
||||||
|
if (heightChanged && container) {
|
||||||
|
// Та же длительность и easing, что у переезда строк: нижняя граница
|
||||||
|
// обёртки должна идти ровно за последней строкой, иначе рассинхрон видно.
|
||||||
|
// Обрезку лишнего на время анимации берёт на себя overflow-y: hidden
|
||||||
|
// (см. .matrixScroll в approval.module.scss).
|
||||||
|
running.current.push(
|
||||||
|
container.animate([{ height: `${heightFrom as number}px` }, { height: `${heightTo as number}px` }], {
|
||||||
|
duration: MOVE_MS,
|
||||||
|
easing: MOVE_EASING,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
moved.forEach(({ el, key, shift }) => {
|
moved.forEach(({ el, key, shift }) => {
|
||||||
const isActive = activeKeys.indexOf(key) !== -1;
|
const isActive = activeKeys.indexOf(key) !== -1;
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { RequestTypeId } from '../../models/types';
|
||||||
|
import { getCurrentListPartition } from './approvalRoute.service';
|
||||||
|
import { registerCurrentPartition } from './listPartitions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Партиция, в которую фактически писать при сохранении заявки.
|
||||||
|
*
|
||||||
|
* existingId задан → редактируем существующую заявку, пишем в ту же партицию
|
||||||
|
* (editListId), запись новых партиций тут ни при чём.
|
||||||
|
*
|
||||||
|
* existingId не задан → создаём новую заявку, и партицию для записи спрашиваем
|
||||||
|
* у approval-route ПРЯМО СЕЙЧАС (а не берём из кеша onInit) — именно этот вызов
|
||||||
|
* проверяет порог элементов текущего списка и ставит в очередь создание
|
||||||
|
* следующей партиции при достижении; без него архивация никогда не сработает,
|
||||||
|
* сколько бы заявок ни создали.
|
||||||
|
*
|
||||||
|
* Свежерезолвленная партиция сразу вписывается в локальный кеш
|
||||||
|
* (registerCurrentPartition): если её создало провижинирование ПРЯМО ЭТИМ
|
||||||
|
* запросом (порог как раз достигнут), кеш из onInit о ней ещё не знает, и без
|
||||||
|
* этого следующий же шаг сохранения (обычно резолв имени списка согласования
|
||||||
|
* через approvalListTitleForListId) упал бы с «список не найден в реестре
|
||||||
|
* партиций», хотя данные уже успешно записались и ссылка совершенно рабочая —
|
||||||
|
* ровно так это и проявилось на форме «Обходной лист».
|
||||||
|
*
|
||||||
|
* Сбой запроса к approval-route не должен блокировать подачу заявки — откат на
|
||||||
|
* editListId (для новой заявки это undefined → список по конвенции имён,
|
||||||
|
* прежнее поведение до появления партиционирования).
|
||||||
|
*
|
||||||
|
* Раньше эта логика (try/catch + getCurrentListPartition + registerCurrentPartition)
|
||||||
|
* была скопирована в save() каждой из 8 форм — при первом же баге в ней пришлось
|
||||||
|
* чинить один и тот же код в 8 файлах. Вынесена сюда, чтобы чинить один раз.
|
||||||
|
*/
|
||||||
|
export async function resolveWriteListId(
|
||||||
|
context: WebPartContext,
|
||||||
|
formId: RequestTypeId,
|
||||||
|
requestTypeId: number,
|
||||||
|
existingId: number | undefined,
|
||||||
|
editListId: string | undefined
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (existingId) return editListId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const partition = await getCurrentListPartition(context, requestTypeId);
|
||||||
|
registerCurrentPartition(formId, partition);
|
||||||
|
return partition.listId;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Не удалось получить текущую партицию списка — используется список по конвенции имён', e);
|
||||||
|
return editListId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,10 +23,11 @@ export const APPROVAL_API_BASE_URL_PROD = 'https://api.tnsu.kz/approval-route';
|
|||||||
export const APPROVAL_API_BASE_URL_LOCAL = 'http://localhost:5080';
|
export const APPROVAL_API_BASE_URL_LOCAL = 'http://localhost:5080';
|
||||||
|
|
||||||
// Настраивается свойством веб-части approvalApiBaseUrl (селект в панели свойств,
|
// Настраивается свойством веб-части approvalApiBaseUrl (селект в панели свойств,
|
||||||
// см. RequestPortalWebPart.render) — тот же приём, что и у debugUserEmail/
|
// см. RequestPortalWebPart.render) — тот же приём, что и у debugUserQuery/
|
||||||
// setImpersonatedEmail: модульный синглтон, а не проброс параметром через все
|
// setImpersonatedIdentity (shared/impersonation.ts): модульный синглтон, а не
|
||||||
// вызовы approvalRoute.service.ts, потому что эти вызовы разбросаны по формам и
|
// проброс параметром через все вызовы approvalRoute.service.ts, потому что эти
|
||||||
// не имеют общего входа, через который можно было бы прокинуть контекст.
|
// вызовы разбросаны по формам и не имеют общего входа, через который можно
|
||||||
|
// было бы прокинуть контекст.
|
||||||
let _approvalApiBaseUrl = APPROVAL_API_BASE_URL_PROD;
|
let _approvalApiBaseUrl = APPROVAL_API_BASE_URL_PROD;
|
||||||
|
|
||||||
export function setApprovalApiBaseUrl(url: string): void {
|
export function setApprovalApiBaseUrl(url: string): void {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import '@pnp/sp/webs';
|
||||||
|
import '@pnp/sp/lists';
|
||||||
|
import '@pnp/sp/items';
|
||||||
|
import { IList } from '@pnp/sp/lists';
|
||||||
|
import { getSP } from './pnp/pnpjs';
|
||||||
|
import { buildRequestUniqueCode } from './requestCode';
|
||||||
|
import { dataListTitle } from './listTitles';
|
||||||
|
import { RequestTypeId } from '../models/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Список данных, в который фактически пишем: listId (конкретная партиция —
|
||||||
|
* либо ОБНОВЛЯЕМОЙ заявки, либо только что резолвленная текущая, см.
|
||||||
|
* getCurrentListPartition) или, если он не задан, список по конвенции имён
|
||||||
|
* (см. dataListTitle) — тот же откат, что действовал до партиционирования.
|
||||||
|
*
|
||||||
|
* Раньше эта строка (`listId ? getSP().web.getList(listId) : ...`) была
|
||||||
|
* скопирована в save/update-функции каждой формы и в submit.ts отдельных
|
||||||
|
* форм (Ticket1C — Planner, Clearance — updateApproverFields) — 8+ мест с
|
||||||
|
* одинаковым кодом. Расхождение здесь означало бы, что часть операций тихо
|
||||||
|
* пишет не в ту партицию.
|
||||||
|
*/
|
||||||
|
export function resolveDataList(formId: RequestTypeId, listId?: string): IList {
|
||||||
|
return listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(formId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISaveItemResult {
|
||||||
|
id: number;
|
||||||
|
/** Задан только при создании новой заявки (существующий код не меняется). */
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Общее ядро сохранения элемента списка данных: резолв списка, создание/обновление,
|
||||||
|
* генерация уникального кода заявки. Вынесено из saveXRequest каждой формы — сам
|
||||||
|
* набор полей (buildItem) и работа с маршрутом (writeRoute) остаются у формы,
|
||||||
|
* форма отличается только ими.
|
||||||
|
*
|
||||||
|
* existingId задан → обновляем ту же запись, иначе создаём новую.
|
||||||
|
* codePrefix — обычно числовой RequestTypeId, но может быть строкой-меткой
|
||||||
|
* (см. buildRequestUniqueCode, TICKET1C_CODE_PREFIX).
|
||||||
|
*/
|
||||||
|
export async function saveRequestItem(
|
||||||
|
formId: RequestTypeId,
|
||||||
|
existingId: number | undefined,
|
||||||
|
listId: string | undefined,
|
||||||
|
codePrefix: number | string,
|
||||||
|
item: Record<string, unknown>
|
||||||
|
): Promise<ISaveItemResult> {
|
||||||
|
const list = resolveDataList(formId, listId);
|
||||||
|
|
||||||
|
if (existingId) {
|
||||||
|
await list.items.getById(existingId).update(item);
|
||||||
|
return { id: existingId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const added = await list.items.add(item);
|
||||||
|
const id = (added as { Id: number }).Id;
|
||||||
|
// Код известен только после создания элемента (нужен его Id).
|
||||||
|
const code = buildRequestUniqueCode(codePrefix, id, new Date());
|
||||||
|
await list.items.getById(id).update({ RequestUniqueCode: code });
|
||||||
|
return { id, code };
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config';
|
||||||
|
import { ICompany } from './models/ICompany';
|
||||||
|
|
||||||
|
// GET /Dictionary/Companies — справочник компаний, ответ — плоский массив
|
||||||
|
// { Id, Name } (без обёртки, как /Dictionary/Departments).
|
||||||
|
async function fetchCompanies(context: WebPartContext): Promise<ICompany[]> {
|
||||||
|
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
||||||
|
const response: HttpClientResponse = await client.get(
|
||||||
|
`${DICTIONARY_API_BASE_URL}/Dictionary/Companies`,
|
||||||
|
AadHttpClient.configurations.v1
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Dictionary/Companies: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Справочник в рамках сессии не меняется — грузим один раз и отдаём тот же
|
||||||
|
// промис повторным вызовам (как в departments.service).
|
||||||
|
let cache: Promise<ICompany[]> | undefined;
|
||||||
|
export function getCompanies(context: WebPartContext): Promise<ICompany[]> {
|
||||||
|
if (!cache) {
|
||||||
|
cache = fetchCompanies(context).catch((e) => {
|
||||||
|
cache = undefined;
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
@@ -6,3 +6,11 @@ export { getProjects } from './projects.service';
|
|||||||
export type { IProject } from './models/IProject';
|
export type { IProject } from './models/IProject';
|
||||||
export { ProjectPicker } from './ui/ProjectPicker';
|
export { ProjectPicker } from './ui/ProjectPicker';
|
||||||
export type { IProjectPickerProps } from './ui/ProjectPicker';
|
export type { IProjectPickerProps } from './ui/ProjectPicker';
|
||||||
|
export { getCompanies } from './companies.service';
|
||||||
|
export type { ICompany } from './models/ICompany';
|
||||||
|
export { CompanyPickerMulti } from './ui/CompanyPickerMulti';
|
||||||
|
export type { ICompanyPickerMultiProps } from './ui/CompanyPickerMulti';
|
||||||
|
export { getProjects1C } from './projects1c.service';
|
||||||
|
export type { IProject1C } from './models/IProject1C';
|
||||||
|
export { Project1CPicker } from './ui/Project1CPicker';
|
||||||
|
export type { IProject1CPickerProps } from './ui/Project1CPicker';
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// GET https://api.tnsu.kz/Dictionary/Companies → плоский массив { Id, Name }
|
||||||
|
// (PascalCase, без обёртки — как /Dictionary/Departments).
|
||||||
|
export interface ICompany {
|
||||||
|
/** GUID компании — стабильный идентификатор. */
|
||||||
|
Id: string;
|
||||||
|
/** Наименование (напр. «ТОО AFL Engineering») — то, что видит пользователь. */
|
||||||
|
Name: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// GET https://api.tnsu.kz/Dictionary/Projects1C → плоский массив
|
||||||
|
// { Id, Code, ProjectName } (PascalCase, без обёртки — как /Dictionary/Companies).
|
||||||
|
// Это отдельный справочник 1С-проектов; не путать с /Project (approval-route).
|
||||||
|
export interface IProject1C {
|
||||||
|
/** GUID проекта — стабильный идентификатор. */
|
||||||
|
Id: string;
|
||||||
|
/** Код проекта (напр. «БК0000069»). */
|
||||||
|
Code: string;
|
||||||
|
/** Наименование проекта — то, что видит пользователь. */
|
||||||
|
ProjectName: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config';
|
||||||
|
import { IProject1C } from './models/IProject1C';
|
||||||
|
|
||||||
|
// GET /Dictionary/Projects1C — справочник проектов 1С, ответ — плоский массив
|
||||||
|
// { Id, Code, ProjectName } (без обёртки, как /Dictionary/Companies).
|
||||||
|
async function fetchProjects1C(context: WebPartContext): Promise<IProject1C[]> {
|
||||||
|
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
||||||
|
const response: HttpClientResponse = await client.get(
|
||||||
|
`${DICTIONARY_API_BASE_URL}/Dictionary/Projects1C`,
|
||||||
|
AadHttpClient.configurations.v1
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Dictionary/Projects1C: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Справочник в рамках сессии не меняется — грузим один раз и отдаём тот же
|
||||||
|
// промис повторным вызовам (как в companies/departments.service).
|
||||||
|
let cache: Promise<IProject1C[]> | undefined;
|
||||||
|
export function getProjects1C(context: WebPartContext): Promise<IProject1C[]> {
|
||||||
|
if (!cache) {
|
||||||
|
cache = fetchProjects1C(context).catch((e) => {
|
||||||
|
cache = undefined;
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $sp-1;
|
||||||
|
margin-bottom: $sp-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 5px 6px 5px 10px;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: $navy-50;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-900;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipRemove {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: $gray-500;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gray-200;
|
||||||
|
color: $gray-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 13px;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $white;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: $gray-900;
|
||||||
|
transition: .18s;
|
||||||
|
|
||||||
|
@include input-focus-gold;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-top: $sp-1;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
box-shadow: $sh-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-900;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $navy-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import styles from './CompanyPickerMulti.module.scss';
|
||||||
|
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||||
|
import { getCompanies } from '../companies.service';
|
||||||
|
import { ICompany } from '../models/ICompany';
|
||||||
|
|
||||||
|
export interface ICompanyPickerMultiProps {
|
||||||
|
context: WebPartContext;
|
||||||
|
id?: string;
|
||||||
|
/** Выбранные компании (Id + Name). */
|
||||||
|
value: ICompany[];
|
||||||
|
onChange: (companies: ICompany[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
/** Только чтение (режим согласующего). */
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_SUGGESTIONS = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мультивыбор компаний из справочника (GET /Dictionary/Companies): ввод текста
|
||||||
|
* фильтрует список, выбранные показываются чипами. Хранит массив { Id, Name }.
|
||||||
|
*/
|
||||||
|
export const CompanyPickerMulti: React.FC<ICompanyPickerMultiProps> = ({
|
||||||
|
context,
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
readOnly
|
||||||
|
}) => {
|
||||||
|
const contextReadOnly = useFormReadOnly();
|
||||||
|
const effectiveReadOnly = readOnly ?? contextReadOnly;
|
||||||
|
|
||||||
|
const [companies, setCompanies] = React.useState<ICompany[]>([]);
|
||||||
|
const [query, setQuery] = React.useState<string>('');
|
||||||
|
const [open, setOpen] = React.useState<boolean>(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
getCompanies(context)
|
||||||
|
.then((list) => {
|
||||||
|
if (alive) setCompanies(list);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [context]);
|
||||||
|
|
||||||
|
const selectedIds = React.useMemo(() => new Set(value.map((c) => c.Id)), [value]);
|
||||||
|
|
||||||
|
const suggestions = React.useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return [];
|
||||||
|
return companies
|
||||||
|
.filter((c) => !selectedIds.has(c.Id) && c.Name.toLowerCase().indexOf(q) !== -1)
|
||||||
|
.slice(0, MAX_SUGGESTIONS);
|
||||||
|
}, [companies, query, selectedIds]);
|
||||||
|
|
||||||
|
const add = (company: ICompany): void => {
|
||||||
|
onChange([...value, { Id: company.Id, Name: company.Name }]);
|
||||||
|
setQuery('');
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (companyId: string): void => {
|
||||||
|
onChange(value.filter((c) => c.Id !== companyId));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
{value.length > 0 && (
|
||||||
|
<div className={styles.chips}>
|
||||||
|
{value.map((c) => (
|
||||||
|
<span key={c.Id} className={styles.chip}>
|
||||||
|
{c.Name}
|
||||||
|
{!effectiveReadOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.chipRemove}
|
||||||
|
aria-label={`Убрать ${c.Name}`}
|
||||||
|
title="Убрать"
|
||||||
|
onClick={() => remove(c.Id)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!effectiveReadOnly && (
|
||||||
|
<div className={styles.field}>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
className={styles.input}
|
||||||
|
value={query}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onBlur={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
{open && suggestions.length > 0 && (
|
||||||
|
<div className={styles.list}>
|
||||||
|
{suggestions.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.Id}
|
||||||
|
type="button"
|
||||||
|
className={styles.item}
|
||||||
|
// preventDefault на mousedown — иначе input теряет фокус (onBlur) раньше onClick.
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => add(c)}
|
||||||
|
>
|
||||||
|
{c.Name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
// справа место под крестик-очистку
|
||||||
|
padding: 11px 36px 11px 13px;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $white;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: $gray-900;
|
||||||
|
transition: .18s;
|
||||||
|
|
||||||
|
@include input-focus-gold;
|
||||||
|
@include readonly-field(':read-only');
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 8px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: transparent;
|
||||||
|
color: $gray-400;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gray-100;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-top: $sp-1;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
box-shadow: $sh-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-900;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $navy-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
font-size: $fs-caption;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import styles from './Project1CPicker.module.scss';
|
||||||
|
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||||
|
import { getProjects1C } from '../projects1c.service';
|
||||||
|
import { IProject1C } from '../models/IProject1C';
|
||||||
|
|
||||||
|
export interface IProject1CPickerProps {
|
||||||
|
context: WebPartContext;
|
||||||
|
id?: string;
|
||||||
|
/** Текущее наименование проекта (то, что видно в поле). */
|
||||||
|
value: string;
|
||||||
|
/**
|
||||||
|
* name — введённый/выбранный текст; id — GUID проекта ('' при ручном вводе);
|
||||||
|
* code — код проекта ('' при ручном вводе). При выборе из списка приходят все три.
|
||||||
|
*/
|
||||||
|
onChange: (name: string, id: string, code: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
/** Только чтение (режим согласующего). */
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_SUGGESTIONS = 30;
|
||||||
|
|
||||||
|
/** Поле выбора проекта из справочника 1С (GET /Dictionary/Projects1C): ввод текста фильтрует список по названию/коду. */
|
||||||
|
export const Project1CPicker: React.FC<IProject1CPickerProps> = ({
|
||||||
|
context,
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
readOnly
|
||||||
|
}) => {
|
||||||
|
const contextReadOnly = useFormReadOnly();
|
||||||
|
const effectiveReadOnly = readOnly ?? contextReadOnly;
|
||||||
|
|
||||||
|
const [projects, setProjects] = React.useState<IProject1C[]>([]);
|
||||||
|
const [open, setOpen] = React.useState<boolean>(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
getProjects1C(context)
|
||||||
|
.then((list) => {
|
||||||
|
if (alive) setProjects(list);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [context]);
|
||||||
|
|
||||||
|
const suggestions = React.useMemo(() => {
|
||||||
|
const query = value.trim().toLowerCase();
|
||||||
|
if (!query) return [];
|
||||||
|
return projects
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
p.ProjectName.toLowerCase().indexOf(query) !== -1 ||
|
||||||
|
(p.Code && p.Code.toLowerCase().indexOf(query) !== -1)
|
||||||
|
)
|
||||||
|
.slice(0, MAX_SUGGESTIONS);
|
||||||
|
}, [projects, value]);
|
||||||
|
|
||||||
|
const handlePick = (project: IProject1C): void => {
|
||||||
|
onChange(project.ProjectName, project.Id, project.Code);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
className={styles.input}
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
readOnly={effectiveReadOnly}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value, '', '');
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onBlur={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
{!effectiveReadOnly && value.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.clear}
|
||||||
|
aria-label="Очистить"
|
||||||
|
title="Очистить"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
onChange('', '', '');
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!effectiveReadOnly && open && suggestions.length > 0 && (
|
||||||
|
<div className={styles.list}>
|
||||||
|
{suggestions.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.Id}
|
||||||
|
type="button"
|
||||||
|
className={styles.item}
|
||||||
|
// preventDefault на mousedown — иначе input теряет фокус (onBlur) раньше onClick.
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => handlePick(p)}
|
||||||
|
>
|
||||||
|
{p.ProjectName}
|
||||||
|
<span className={styles.meta}>{p.Code}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import '@pnp/sp/webs';
|
||||||
|
import '@pnp/sp/site-users/web';
|
||||||
|
import { getSP } from './pnp/pnpjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка).
|
||||||
|
* Было скопировано в submit.ts нескольких форм с сотрудником-инициатором
|
||||||
|
* (Vacation, Clearance) — вынесено сюда, чтобы не разойтись при правке.
|
||||||
|
*/
|
||||||
|
export async function ensureEmployee(login: string): Promise<{ Id: number; Title: string }> {
|
||||||
|
const full = login.indexOf('|') >= 0 ? login : `i:0#.f|membership|${login}`;
|
||||||
|
return getSP().web.ensureUser(full);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { SPHttpClient } from '@microsoft/sp-http';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ОТЛАДОЧНАЯ подмена «текущего пользователя»: портал ведёт себя так, будто им
|
* ОТЛАДОЧНАЯ подмена «текущего пользователя»: портал ведёт себя так, будто им
|
||||||
@@ -22,10 +23,12 @@ import { WebPartContext } from '@microsoft/sp-webpart-base';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
let impersonatedEmail = '';
|
let impersonatedEmail = '';
|
||||||
|
let impersonatedDisplayName = '';
|
||||||
|
|
||||||
/** Вызывается веб-частью на каждый render — свойство могли поменять в панели. */
|
/** Вызывается веб-частью на каждый render — свойство могли поменять в панели. */
|
||||||
export function setImpersonatedEmail(email: string | undefined): void {
|
export function setImpersonatedIdentity(email: string | undefined, displayName?: string): void {
|
||||||
impersonatedEmail = (email || '').trim();
|
impersonatedEmail = (email || '').trim();
|
||||||
|
impersonatedDisplayName = (displayName || '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Заданная подмена ('' — подмены нет). Для предупреждающей полосы в интерфейсе. */
|
/** Заданная подмена ('' — подмены нет). Для предупреждающей полосы в интерфейсе. */
|
||||||
@@ -33,7 +36,89 @@ export function getImpersonatedEmail(): string {
|
|||||||
return impersonatedEmail;
|
return impersonatedEmail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ФИО подменённого сотрудника, если его удалось найти поиском по имени ('' — для e-mail, введённого напрямую). */
|
||||||
|
export function getImpersonatedDisplayName(): string {
|
||||||
|
return impersonatedDisplayName;
|
||||||
|
}
|
||||||
|
|
||||||
/** Email, под которым портал работает: подменённый, если задан, иначе — из токена. */
|
/** Email, под которым портал работает: подменённый, если задан, иначе — из токена. */
|
||||||
export function effectiveUserEmail(context: WebPartContext): string {
|
export function effectiveUserEmail(context: WebPartContext): string {
|
||||||
return impersonatedEmail || context.pageContext.user.email || '';
|
return impersonatedEmail || context.pageContext.user.email || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IResolvedDebugUser {
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPeopleSearchHit {
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сырой ответ ClientPeoplePickerSearchUser — value внутри двойного JSON. */
|
||||||
|
interface IPeoplePickerEntity {
|
||||||
|
DisplayText?: string;
|
||||||
|
Description?: string;
|
||||||
|
EntityData?: { Email?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchPeopleByText(context: WebPartContext, queryText: string): Promise<IPeopleSearchHit[]> {
|
||||||
|
const url = `${context.pageContext.web.absoluteUrl}/_api/SP.UI.ApplicationPages.ClientPeoplePickerWebServiceInterface.clientPeoplePickerSearchUser`;
|
||||||
|
const body = JSON.stringify({
|
||||||
|
queryParams: {
|
||||||
|
AllowEmailAddresses: true,
|
||||||
|
AllowMultipleEntities: false,
|
||||||
|
MaximumEntitySuggestions: 5,
|
||||||
|
PrincipalSource: 15, // All
|
||||||
|
PrincipalType: 1, // User
|
||||||
|
QueryString: queryText
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await context.spHttpClient.post(url, SPHttpClient.configurations.v1, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json;odata=nometadata',
|
||||||
|
'Content-Type': 'application/json;odata=nometadata'
|
||||||
|
},
|
||||||
|
body
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) return [];
|
||||||
|
|
||||||
|
const json: { value?: string } = await response.json();
|
||||||
|
if (!json.value) return [];
|
||||||
|
|
||||||
|
let entries: IPeoplePickerEntity[];
|
||||||
|
try {
|
||||||
|
entries = JSON.parse(json.value);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
.map((e) => ({ displayName: e.DisplayText || '', email: e.EntityData?.Email || e.Description || '' }))
|
||||||
|
.filter((e) => !!e.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ищет сотрудника по имени для отладочной подмены (см. property pane —
|
||||||
|
* PropertyPaneDebugUser/DebugUserField). Порядок «Фамилия Имя» или «Имя
|
||||||
|
* Фамилия» не важен: если прямой запрос ничего не находит, пробуем с
|
||||||
|
* переставленными первыми двумя словами.
|
||||||
|
*/
|
||||||
|
export async function resolveDebugUser(context: WebPartContext, nameQuery: string): Promise<IResolvedDebugUser | undefined> {
|
||||||
|
const text = nameQuery.trim();
|
||||||
|
if (!text) return undefined;
|
||||||
|
|
||||||
|
let hits = await searchPeopleByText(context, text);
|
||||||
|
if (hits.length === 0) {
|
||||||
|
const tokens = text.split(/\s+/).filter(Boolean);
|
||||||
|
if (tokens.length >= 2) {
|
||||||
|
const swapped = [tokens[1], tokens[0], ...tokens.slice(2)].join(' ');
|
||||||
|
hits = await searchPeopleByText(context, swapped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hits[0];
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,12 +4,21 @@
|
|||||||
// достаточно для нечастых событий (сохранение формы, решение согласующего).
|
// достаточно для нечастых событий (сохранение формы, решение согласующего).
|
||||||
const EVENT_NAME = 'rp-requests-changed';
|
const EVENT_NAME = 'rp-requests-changed';
|
||||||
|
|
||||||
export function emitRequestsChanged(): void {
|
/**
|
||||||
window.dispatchEvent(new Event(EVENT_NAME));
|
* force=true — повторный клик по УЖЕ открытому пункту меню (см. goto в
|
||||||
|
* RequestPortal.tsx): пользователь явно просит обновить список, поэтому таблица
|
||||||
|
* прячет прежние строки и показывает «Загрузка…», пока не придёт свежий ответ.
|
||||||
|
* force=false/не задан — фоновые обновления (создание заявки, решение
|
||||||
|
* согласующего, десятиминутный автообновление): строки остаются на экране, чтобы
|
||||||
|
* не моргать, если данные не изменились.
|
||||||
|
*/
|
||||||
|
export function emitRequestsChanged(force?: boolean): void {
|
||||||
|
window.dispatchEvent(new CustomEvent<{ force?: boolean }>(EVENT_NAME, { detail: { force } }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Подписка на изменения. Возвращает функцию отписки (вызвать в cleanup эффекта). */
|
/** Подписка на изменения. Возвращает функцию отписки (вызвать в cleanup эффекта). */
|
||||||
export function onRequestsChanged(handler: () => void): () => void {
|
export function onRequestsChanged(handler: (force?: boolean) => void): () => void {
|
||||||
window.addEventListener(EVENT_NAME, handler);
|
const listener = (e: Event): void => handler((e as CustomEvent<{ force?: boolean }>).detail?.force);
|
||||||
return () => window.removeEventListener(EVENT_NAME, handler);
|
window.addEventListener(EVENT_NAME, listener);
|
||||||
|
return () => window.removeEventListener(EVENT_NAME, listener);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user