Параллельные шаги #3
@@ -12,6 +12,7 @@ import * as strings from 'RequestPortalWebPartStrings';
|
|||||||
import RequestPortal from './components/RequestPortal';
|
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 { ErrorOverlay } from './shared/ui';
|
||||||
|
|
||||||
export interface IRequestPortalWebPartProps {
|
export interface IRequestPortalWebPartProps {
|
||||||
description: string;
|
description: string;
|
||||||
@@ -23,16 +24,17 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
|||||||
private _environmentMessage: string = '';
|
private _environmentMessage: string = '';
|
||||||
|
|
||||||
public render(): void {
|
public render(): void {
|
||||||
const element: React.ReactElement<IRequestPortalProps> = React.createElement(
|
const element: React.ReactElement = React.createElement(
|
||||||
RequestPortal,
|
ErrorOverlay,
|
||||||
{
|
undefined,
|
||||||
|
React.createElement<IRequestPortalProps>(RequestPortal, {
|
||||||
description: this.properties.description,
|
description: this.properties.description,
|
||||||
isDarkTheme: this._isDarkTheme,
|
isDarkTheme: this._isDarkTheme,
|
||||||
environmentMessage: this._environmentMessage,
|
environmentMessage: this._environmentMessage,
|
||||||
hasTeamsContext: !!this.context.sdks.microsoftTeams,
|
hasTeamsContext: !!this.context.sdks.microsoftTeams,
|
||||||
userDisplayName: this.context.pageContext.user.displayName,
|
userDisplayName: this.context.pageContext.user.displayName,
|
||||||
context: this.context
|
context: this.context
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
ReactDom.render(element, this.domElement);
|
ReactDom.render(element, this.domElement);
|
||||||
|
|||||||
@@ -73,6 +73,14 @@
|
|||||||
margin-bottom: $sp-6;
|
margin-bottom: $sp-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Раздел «Новая заявка», когда открыта другая вкладка. Именно скрытие, а не
|
||||||
|
// размонтирование: заполненная форма должна пережить переключение вкладок
|
||||||
|
// (см. RequestPortal.tsx). display:none выводит содержимое и из потока, и из
|
||||||
|
// последовательности табуляции — скрытая форма не ловит фокус с клавиатуры.
|
||||||
|
.hiddenPane {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
// На узких экранах рейл уходит наверх.
|
// На узких экранах рейл уходит наверх.
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.layout {
|
.layout {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { IRequestPortalProps } from './IRequestPortalProps';
|
|||||||
import { IconSprite } from '../shared/ui/Icon/IconSprite';
|
import { IconSprite } from '../shared/ui/Icon/IconSprite';
|
||||||
import { navigate, useRoute, NavSection } from '../shared/routing';
|
import { navigate, useRoute, NavSection } from '../shared/routing';
|
||||||
import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents';
|
import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents';
|
||||||
|
import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges';
|
||||||
import { SideNav } from './SideNav/SideNav';
|
import { SideNav } from './SideNav/SideNav';
|
||||||
import { Drawer } from './Drawer/Drawer';
|
import { Drawer } from './Drawer/Drawer';
|
||||||
import { CategoryTabs } from './CategoryTabs/CategoryTabs';
|
import { CategoryTabs } from './CategoryTabs/CategoryTabs';
|
||||||
@@ -11,9 +12,12 @@ import { CATEGORIES, DEFAULT_CATEGORY_ID } from '../data/categories';
|
|||||||
import { REQUEST_TYPES } from '../data/requests';
|
import { REQUEST_TYPES } from '../data/requests';
|
||||||
import SubmitFeature from '../features/submit/SubmitFeature';
|
import SubmitFeature from '../features/submit/SubmitFeature';
|
||||||
import ApproveFeature from '../features/approve/ApproveFeature';
|
import ApproveFeature from '../features/approve/ApproveFeature';
|
||||||
|
import EditFeature from '../features/edit/EditFeature';
|
||||||
import { RequestsTableView } from '../features/myRequests/RequestsTableView';
|
import { RequestsTableView } from '../features/myRequests/RequestsTableView';
|
||||||
import { SearchView } from '../features/myRequests/SearchView';
|
import { SearchView } from '../features/myRequests/SearchView';
|
||||||
import { getPendingApproval } from '../features/myRequests/myRequests.service';
|
import { getPendingApproval } from '../features/myRequests/myRequests.service';
|
||||||
|
import { IMyRequest } from '../features/myRequests/models/IMyRequest';
|
||||||
|
import { isEditableStatus } from '../shared/approval';
|
||||||
|
|
||||||
// Оболочка портала: тёмное боковое меню (разделы) + основная область.
|
// Оболочка портала: тёмное боковое меню (разделы) + основная область.
|
||||||
// Логики фич тут нет — файл общий, меняется редко.
|
// Логики фич тут нет — файл общий, меняется редко.
|
||||||
@@ -68,13 +72,28 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
|
|
||||||
// Переход «создать на основе» (в т.ч. из открытой заявки в панели): переключаем
|
// Переход «создать на основе» (в т.ч. из открытой заявки в панели): переключаем
|
||||||
// вкладку категорий на категорию целевой формы, чтобы она совпала с открытой формой.
|
// вкладку категорий на категорию целевой формы, чтобы она совпала с открытой формой.
|
||||||
|
// Форма при этом пересоздаётся (см. key у SubmitFeature), поэтому прежние
|
||||||
|
// несохранённые правки к ней уже не относятся — снимаем флаг.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (route.mode === 'create' && route.formKey) {
|
if (route.mode === 'create' && route.formKey) {
|
||||||
const found = REQUEST_TYPES.filter((r) => r.id === route.formKey)[0];
|
const found = REQUEST_TYPES.filter((r) => r.id === route.formKey)[0];
|
||||||
if (found) setActiveCategoryId(found.categoryId);
|
if (found) setActiveCategoryId(found.categoryId);
|
||||||
|
markFormClean();
|
||||||
}
|
}
|
||||||
}, [route.mode, route.formKey]);
|
}, [route.mode, route.formKey]);
|
||||||
|
|
||||||
|
// Сохранение (в том числе черновика) шлёт rp-requests-changed — после него
|
||||||
|
// терять нечего, предупреждать при «создать на основе» больше не о чем.
|
||||||
|
React.useEffect(() => onRequestsChanged(markFormClean), []);
|
||||||
|
|
||||||
|
// Смена категории закрывает открытую форму (SubmitFeature сбрасывает выбор,
|
||||||
|
// когда категория перестаёт совпадать) — значит введённое пропадёт.
|
||||||
|
const changeCategory = (categoryId: string): void => {
|
||||||
|
if (!confirmDiscardFormChanges()) return;
|
||||||
|
markFormClean();
|
||||||
|
setActiveCategoryId(categoryId);
|
||||||
|
};
|
||||||
|
|
||||||
const goto = (nav: NavSection): void => {
|
const goto = (nav: NavSection): void => {
|
||||||
// Клик по УЖЕ активному пункту меню не меняет маршрут, значит эффект
|
// Клик по УЖЕ активному пункту меню не меняет маршрут, значит эффект
|
||||||
// загрузки в таблице не перезапустится сам. Шлём событие обновления —
|
// загрузки в таблице не перезапустится сам. Шлём событие обновления —
|
||||||
@@ -83,8 +102,28 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
if (nav === route.nav && route.mode === 'submit') emitRequestsChanged();
|
if (nav === route.nav && route.mode === 'submit') emitRequestsChanged();
|
||||||
navigate({ mode: 'submit', nav });
|
navigate({ mode: 'submit', nav });
|
||||||
};
|
};
|
||||||
|
// Клик по строке таблицы. СВОЯ заявка в незавершённом статусе (черновик,
|
||||||
|
// возвращённая, отклонённая) открывается на редактирование, всё остальное —
|
||||||
|
// на просмотр: чужую заявку править нельзя, а свою уже ушедшую по маршруту —
|
||||||
|
// тем более (её статус в isEditableStatus не входит).
|
||||||
|
const openRequest = (request: IMyRequest): void =>
|
||||||
|
navigate({
|
||||||
|
mode: request.kind === 'own' && isEditableStatus(request.status) ? 'edit' : 'approve',
|
||||||
|
nav: route.nav,
|
||||||
|
formKey: request.formKey,
|
||||||
|
itemId: request.id,
|
||||||
|
});
|
||||||
|
|
||||||
const closeDrawer = (): void => navigate({ mode: 'submit', nav: route.nav });
|
const closeDrawer = (): void => navigate({ mode: 'submit', nav: route.nav });
|
||||||
|
|
||||||
|
// Закрытие панели РЕДАКТИРОВАНИЯ: там открыта форма, и её содержимое живёт
|
||||||
|
// только в памяти — уходя без сохранения, пользователь теряет правки.
|
||||||
|
const closeEditDrawer = (): void => {
|
||||||
|
if (!confirmDiscardFormChanges()) return;
|
||||||
|
markFormClean();
|
||||||
|
closeDrawer();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={rootClasses}>
|
<section className={rootClasses}>
|
||||||
<IconSprite />
|
<IconSprite />
|
||||||
@@ -100,16 +139,35 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main className={styles.main}>
|
<main className={styles.main}>
|
||||||
{route.nav === 'new' ? (
|
{/* Раздел «Новая заявка» держим СМОНТИРОВАННЫМ и просто прячем стилем,
|
||||||
<>
|
когда открыта другая вкладка. Иначе React размонтировал бы форму, и
|
||||||
|
всё введённое пропадало бы при переключении на «Мои заявки» и
|
||||||
|
обратно. Ключ завязан на «создать на основе»: обычные переключения
|
||||||
|
вкладок его не меняют (состояние живёт), а вот открытие новой
|
||||||
|
заявки на основе исходной пересоздаёт форму — SubmitFeature читает
|
||||||
|
initialForm только при монтировании. */}
|
||||||
|
<div
|
||||||
|
className={route.nav === 'new' ? undefined : styles.hiddenPane}
|
||||||
|
// Делегирование: любой пользовательский ввод внутри раздела помечает
|
||||||
|
// форму изменённой. Так не нужно инструментировать каждую форму по
|
||||||
|
// отдельности, и программное заполнение (предзаполнение «на основе»)
|
||||||
|
// сюда не попадает — React выставляет значения без этих событий.
|
||||||
|
onInput={markFormDirty}
|
||||||
|
onChange={markFormDirty}
|
||||||
|
>
|
||||||
<div className={styles.tabsWrap}>
|
<div className={styles.tabsWrap}>
|
||||||
<CategoryTabs
|
<CategoryTabs
|
||||||
categories={CATEGORIES}
|
categories={CATEGORIES}
|
||||||
activeId={activeCategoryId}
|
activeId={activeCategoryId}
|
||||||
onChange={setActiveCategoryId}
|
onChange={changeCategory}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SubmitFeature
|
<SubmitFeature
|
||||||
|
key={
|
||||||
|
route.mode === 'create' && route.formKey && route.sourceId
|
||||||
|
? `create-${route.formKey}-${route.sourceId}`
|
||||||
|
: 'blank'
|
||||||
|
}
|
||||||
context={context}
|
context={context}
|
||||||
activeCategoryId={activeCategoryId}
|
activeCategoryId={activeCategoryId}
|
||||||
initialForm={
|
initialForm={
|
||||||
@@ -118,31 +176,20 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
) : route.nav === 'search' ? (
|
|
||||||
|
{route.nav === 'search' && (
|
||||||
<SearchView
|
<SearchView
|
||||||
context={context}
|
context={context}
|
||||||
onOpen={(request) =>
|
onOpen={openRequest}
|
||||||
navigate({
|
|
||||||
mode: 'approve',
|
|
||||||
nav: route.nav,
|
|
||||||
formKey: request.formKey,
|
|
||||||
itemId: request.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
)}
|
||||||
|
|
||||||
|
{(route.nav === 'approvals' || route.nav === 'own') && (
|
||||||
<RequestsTableView
|
<RequestsTableView
|
||||||
context={context}
|
context={context}
|
||||||
kind={route.nav === 'approvals' ? 'approvals' : 'own'}
|
kind={route.nav === 'approvals' ? 'approvals' : 'own'}
|
||||||
onOpen={(request) =>
|
onOpen={openRequest}
|
||||||
navigate({
|
|
||||||
mode: 'approve',
|
|
||||||
nav: route.nav,
|
|
||||||
formKey: request.formKey,
|
|
||||||
itemId: request.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onMyTurnCountChange={route.nav === 'approvals' ? setApprovalsCount : undefined}
|
onMyTurnCountChange={route.nav === 'approvals' ? setApprovalsCount : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -154,6 +201,21 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
<ApproveFeature context={context} formKey={route.formKey} itemId={route.itemId} />
|
<ApproveFeature context={context} formKey={route.formKey} itemId={route.itemId} />
|
||||||
</Drawer>
|
</Drawer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Своя незавершённая заявка — та же панель, но с формой. Ввод здесь тоже
|
||||||
|
помечает состояние изменённым, поэтому обёртка ловит input/change. */}
|
||||||
|
{route.mode === 'edit' && route.formKey && route.itemId && (
|
||||||
|
<Drawer title={`Заявка №${route.itemId}`} onClose={closeEditDrawer}>
|
||||||
|
<div onInput={markFormDirty} onChange={markFormDirty}>
|
||||||
|
<EditFeature
|
||||||
|
context={context}
|
||||||
|
formKey={route.formKey}
|
||||||
|
itemId={route.itemId}
|
||||||
|
onClose={closeDrawer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import styles from '../approve/ApproveFeature.module.scss';
|
||||||
|
import { getFormComponent } from '../../forms/registry';
|
||||||
|
import { REQUEST_TYPES } from '../../data/requests';
|
||||||
|
import { emitRequestsChanged } from '../../shared/requestEvents';
|
||||||
|
|
||||||
|
export interface IEditFeatureProps {
|
||||||
|
context: WebPartContext;
|
||||||
|
/** Ключ формы (тип заявки) из URL. */
|
||||||
|
formKey: string;
|
||||||
|
/** RowID редактируемой заявки из URL. */
|
||||||
|
itemId: number;
|
||||||
|
/** Закрыть панель (после сохранения). */
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фича «Редактирование»: открывает СВОЮ незавершённую заявку (черновик,
|
||||||
|
// возвращённую, отклонённую) в той же выезжающей панели, где обычно показывается
|
||||||
|
// просмотр. Рендерит ту же форму, что и подача, но с editId — форма подгружает
|
||||||
|
// существующую запись и сохраняет в неё же.
|
||||||
|
//
|
||||||
|
// Отдельно от features/submit намеренно: там форма живёт в потоке «плитки →
|
||||||
|
// новая заявка», а тут — точечное открытие конкретной записи по ссылке.
|
||||||
|
const EditFeature: React.FC<IEditFeatureProps> = ({ context, formKey, itemId, onClose }) => {
|
||||||
|
const FormComponent = getFormComponent(formKey);
|
||||||
|
const requestType = REQUEST_TYPES.filter((r) => r.id === formKey)[0];
|
||||||
|
|
||||||
|
if (!FormComponent || !requestType) {
|
||||||
|
return <div className={styles.state}>Для формы «{formKey}» редактирование не настроено.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.content}>
|
||||||
|
<React.Suspense fallback={<div className={styles.state}>Загрузка формы…</div>}>
|
||||||
|
<FormComponent
|
||||||
|
requestType={requestType}
|
||||||
|
context={context}
|
||||||
|
editId={itemId}
|
||||||
|
onCancel={onClose}
|
||||||
|
onSaved={() => {
|
||||||
|
emitRequestsChanged();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</React.Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditFeature;
|
||||||
@@ -19,6 +19,13 @@ import styles from './RequestsTableView.module.scss';
|
|||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
// Автообновление открытых таблиц: раз в 10 минут перечитываем данные, чтобы
|
||||||
|
// вкладка, оставленная открытой надолго, не показывала устаревший список.
|
||||||
|
// Обновление фоновое — строки с экрана не пропадают и перерисовываются, только
|
||||||
|
// если данные реально изменились (см. pageSignature).
|
||||||
|
const AUTO_REFRESH_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
|
||||||
export interface IRequestsTableViewProps {
|
export interface IRequestsTableViewProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
/** 'approvals' — ждут решения текущего пользователя; 'own' — созданные им. */
|
/** 'approvals' — ждут решения текущего пользователя; 'own' — созданные им. */
|
||||||
@@ -239,9 +246,15 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
const unsubscribe = onRequestsChanged(() => {
|
const unsubscribe = onRequestsChanged(() => {
|
||||||
load().catch(() => undefined);
|
load().catch(() => undefined);
|
||||||
});
|
});
|
||||||
|
// Пока вкладка браузера скрыта, обновляться незачем — при возврате к ней
|
||||||
|
// сработает ближайший тик.
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
if (!document.hidden) load().catch(() => undefined);
|
||||||
|
}, AUTO_REFRESH_MS);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
|
window.clearInterval(timer);
|
||||||
};
|
};
|
||||||
}, [context, types, kind, debouncedQuery, page]);
|
}, [context, types, kind, debouncedQuery, page]);
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import styles from './SearchView.module.scss';
|
|||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
// Автообновление открытых таблиц: раз в 10 минут перечитываем данные, чтобы
|
||||||
|
// вкладка, оставленная открытой надолго, не показывала устаревший список.
|
||||||
|
// Обновление фоновое — строки с экрана не пропадают и перерисовываются, только
|
||||||
|
// если данные реально изменились (см. pageSignature).
|
||||||
|
const AUTO_REFRESH_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
|
||||||
export interface ISearchViewProps {
|
export interface ISearchViewProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
/** Клик по строке — открыть заявку (в выезжающей панели). */
|
/** Клик по строке — открыть заявку (в выезжающей панели). */
|
||||||
@@ -169,9 +176,13 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
|
|||||||
const unsubscribe = onRequestsChanged(() => {
|
const unsubscribe = onRequestsChanged(() => {
|
||||||
load().catch(() => undefined);
|
load().catch(() => undefined);
|
||||||
});
|
});
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
if (!document.hidden) load().catch(() => undefined);
|
||||||
|
}, AUTO_REFRESH_MS);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
|
window.clearInterval(timer);
|
||||||
};
|
};
|
||||||
}, [context, types, debouncedQuery, page]);
|
}, [context, types, debouncedQuery, page]);
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ const RECENT_MS = 10 * 60 * 1000;
|
|||||||
// лимита пишем предупреждение в лог, а не молча обрезаем.
|
// лимита пишем предупреждение в лог, а не молча обрезаем.
|
||||||
const SEARCH_ROW_LIMIT = 500;
|
const SEARCH_ROW_LIMIT = 500;
|
||||||
|
|
||||||
|
// Сколько последних заявок автора дочитываем через REST в довесок к Search
|
||||||
|
// в разделе «Мои заявки» (см. getFreshOwnRowsForType). Это записи ОДНОГО
|
||||||
|
// пользователя, поэтому лимит с запасом; при упоре в него пишем предупреждение.
|
||||||
|
const OWN_REST_TOP = 200;
|
||||||
|
|
||||||
// Managed properties списка согласования. Имена включают группу колонок сайта
|
// Managed properties списка согласования. Имена включают группу колонок сайта
|
||||||
// (TansuSite) — SharePoint генерирует их так, поэтому «ожидаемого»
|
// (TansuSite) — SharePoint генерирует их так, поэтому «ожидаемого»
|
||||||
// ApproverOWSUSER в схеме поиска не находилось (разбирались 2026-08-06).
|
// ApproverOWSUSER в схеме поиска не находилось (разбирались 2026-08-06).
|
||||||
@@ -115,9 +120,6 @@ export async function getPendingApproval(
|
|||||||
const { types, index, debug } = await buildPendingIndex(context, scope);
|
const { types, index, debug } = await buildPendingIndex(context, scope);
|
||||||
|
|
||||||
const pageRows = index.slice((page - 1) * PENDING_PAGE_SIZE, page * PENDING_PAGE_SIZE);
|
const pageRows = index.slice((page - 1) * PENDING_PAGE_SIZE, page * PENDING_PAGE_SIZE);
|
||||||
console.log(
|
|
||||||
`[Текущие заявки] «На согласовании» (scope=${scope}): всего найдено ${index.length}, страница ${page} — ${pageRows.length} строк, дочитываем автора для них`
|
|
||||||
);
|
|
||||||
await hydrateAuthors(context, types, pageRows);
|
await hydrateAuthors(context, types, pageRows);
|
||||||
|
|
||||||
return { rows: pageRows, totalRows: index.length, debug };
|
return { rows: pageRows, totalRows: index.length, debug };
|
||||||
@@ -223,9 +225,6 @@ async function indexApprovalsForType(
|
|||||||
r.RequestStatus === REQUEST_STATUS.onApproval ||
|
r.RequestStatus === REQUEST_STATUS.onApproval ||
|
||||||
r.RequestStatus === REQUEST_STATUS.onExecution
|
r.RequestStatus === REQUEST_STATUS.onExecution
|
||||||
);
|
);
|
||||||
console.log(
|
|
||||||
`[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=myTurn — свежих (REST, < 10 мин): ${recentRows.length}, через Search: ${searchRows.length}, после фильтра по статусу: ${stepRows.length}`
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// 'involved' — используется ТОЛЬКО для текстового поиска (см.
|
// 'involved' — используется ТОЛЬКО для текстового поиска (см.
|
||||||
// getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная
|
// getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная
|
||||||
@@ -235,13 +234,8 @@ async function indexApprovalsForType(
|
|||||||
// зависимости от того, успел ли Search её проиндексировать к моменту
|
// зависимости от того, успел ли Search её проиндексировать к моменту
|
||||||
// REST-окна).
|
// REST-окна).
|
||||||
stepRows = await getInvolvedRowsViaSearch(context, type, approvalList);
|
stepRows = await getInvolvedRowsViaSearch(context, type, approvalList);
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=involved (Search) — найдено: ${stepRows.length}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.table(stepRows.map((r) => ({ Id: r.Id, TicketId: r.TicketId, RequestStatus: r.RequestStatus })));
|
|
||||||
debug.approvalRowCount = stepRows.length;
|
debug.approvalRowCount = stepRows.length;
|
||||||
|
|
||||||
// Дедуп по TicketId: при scope='involved' у одной заявки может найтись
|
// Дедуп по TicketId: при scope='involved' у одной заявки может найтись
|
||||||
@@ -336,10 +330,6 @@ async function getInvolvedRowsViaSearch(
|
|||||||
.map((r) => Number(r.ListItemID))
|
.map((r) => Number(r.ListItemID))
|
||||||
.filter((id) => !isNaN(id) && id > 0);
|
.filter((id) => !isNaN(id) && id > 0);
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[Текущие заявки] Search «${type.name}» (involved): KQL = ${kql}, найдено: ${total}, годных Id: ${ids.length}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (total > SEARCH_ROW_LIMIT) {
|
if (total > SEARCH_ROW_LIMIT) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[Текущие заявки] «${type.name}»: Search вернул ${total} строк, взяли первые ${SEARCH_ROW_LIMIT} — остальные не попадут в выборку`
|
`[Текущие заявки] «${type.name}»: Search вернул ${total} строк, взяли первые ${SEARCH_ROW_LIMIT} — остальные не попадут в выборку`
|
||||||
@@ -513,14 +503,33 @@ async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestTyp
|
|||||||
const me = await sp.web.currentUser.select('Id')();
|
const me = await sp.web.currentUser.select('Id')();
|
||||||
const cutoff = new Date(Date.now() - RECENT_MS).toISOString();
|
const cutoff = new Date(Date.now() - RECENT_MS).toISOString();
|
||||||
|
|
||||||
|
// Фильтруем только по AuthorId, а «свежая или черновик» решаем в JS: статус
|
||||||
|
// в $filter не используем, потому что кириллица в сравнении там уже подводила
|
||||||
|
// (см. indexApprovalsForType). Берём последние OWN_REST_TOP заявок автора —
|
||||||
|
// это его собственные записи, их немного, и запрос ограничен по объёму.
|
||||||
const rows = (await sp.web
|
const rows = (await sp.web
|
||||||
.getList(type.listId as string)
|
.getList(type.listId as string)
|
||||||
.items.filter(`AuthorId eq ${me.Id} and Created ge '${cutoff}'`)
|
.items.filter(`AuthorId eq ${me.Id}`)
|
||||||
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
|
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
|
||||||
.expand('Author')
|
.expand('Author')
|
||||||
.top(50)()) as Array<Record<string, unknown>>;
|
.orderBy('Created', false)
|
||||||
|
.top(OWN_REST_TOP)()) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
return rows.map((r) => toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own'));
|
if (rows.length === OWN_REST_TOP) {
|
||||||
|
console.warn(
|
||||||
|
`[Текущие заявки] «${type.name}»: REST-довесок упёрся в ${OWN_REST_TOP} заявок — более старые черновики сюда не попадут`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Черновик берём ЛЮБОГО возраста: он живёт только у автора, правится часто и
|
||||||
|
// именно по нему индекс Search отстаёт заметнее всего, а увидеть свой
|
||||||
|
// черновик в «Моих заявках» пользователь ожидает сразу.
|
||||||
|
return rows
|
||||||
|
.filter((r) => {
|
||||||
|
const created = (r.Created as string) || '';
|
||||||
|
return r.Status === REQUEST_STATUS.draft || created >= cutoff;
|
||||||
|
})
|
||||||
|
.map((r) => toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[Текущие заявки] «${type.name}»: не удалось дочитать свежие заявки:`, (e as Error).message);
|
console.error(`[Текущие заявки] «${type.name}»: не удалось дочитать свежие заявки:`, (e as Error).message);
|
||||||
return [];
|
return [];
|
||||||
@@ -551,12 +560,6 @@ export async function getOwnRequests(
|
|||||||
const searchKeys = new Set(searchPage.rows.map((r) => `${r.requestTypeId}-${r.id}`));
|
const searchKeys = new Set(searchPage.rows.map((r) => `${r.requestTypeId}-${r.id}`));
|
||||||
const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`));
|
const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`));
|
||||||
|
|
||||||
if (onlyFresh.length > 0) {
|
|
||||||
console.log(
|
|
||||||
`[Текущие заявки] «Мои заявки»: довесили ${onlyFresh.length} свежих заявок (< 10 мин), которых Search ещё не нашёл`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = onlyFresh
|
const merged = onlyFresh
|
||||||
.concat(searchPage.rows)
|
.concat(searchPage.rows)
|
||||||
.sort((a, b) => b.created.localeCompare(a.created))
|
.sort((a, b) => b.created.localeCompare(a.created))
|
||||||
@@ -597,8 +600,6 @@ export async function searchOwnRequests(
|
|||||||
SortList: [{ Property: 'Created', Direction: 1 }],
|
SortList: [{ Property: 'Created', Direction: 1 }],
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[Текущие заявки] Search «Мои заявки»: KQL = ${kql}, всего найдено: ${results.TotalRows}`);
|
|
||||||
console.table(results.PrimarySearchResults);
|
|
||||||
|
|
||||||
const entries = hitsToEntries(results, types);
|
const entries = hitsToEntries(results, types);
|
||||||
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
|
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
|
||||||
@@ -646,8 +647,6 @@ export async function filterPendingByQuery(
|
|||||||
TrimDuplicates: false,
|
TrimDuplicates: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[Текущие заявки] Search «На согласовании»: KQL = ${kql}, найдено: ${results.TotalRows}`);
|
|
||||||
console.table(results.PrimarySearchResults);
|
|
||||||
|
|
||||||
// ListItemID уникален только внутри своего списка — сверяем пару тип+Id.
|
// ListItemID уникален только внутри своего списка — сверяем пару тип+Id.
|
||||||
const found = new Set(hitsToEntries(results, scopeTypes).map((e) => `${e.type.id}-${e.id}`));
|
const found = new Set(hitsToEntries(results, scopeTypes).map((e) => `${e.type.id}-${e.id}`));
|
||||||
@@ -697,8 +696,6 @@ async function searchOwnAll(
|
|||||||
TrimDuplicates: false,
|
TrimDuplicates: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[Текущие заявки] Search «Поиск» (мои заявки): KQL = ${kql}, найдено: ${results.TotalRows}`);
|
|
||||||
console.table(results.PrimarySearchResults);
|
|
||||||
|
|
||||||
const entries = hitsToEntries(results, types);
|
const entries = hitsToEntries(results, types);
|
||||||
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
|
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getRequestsByCategory, REQUEST_TYPES } from '../../data/requests';
|
|||||||
import { getFormComponent } from '../../forms/registry';
|
import { getFormComponent } from '../../forms/registry';
|
||||||
import { IRequestType } from '../../models/types';
|
import { IRequestType } from '../../models/types';
|
||||||
import { emitRequestsChanged } from '../../shared/requestEvents';
|
import { emitRequestsChanged } from '../../shared/requestEvents';
|
||||||
|
import { confirmDiscardFormChanges, markFormClean } from '../../shared/unsavedChanges';
|
||||||
|
|
||||||
export interface ISubmitFeatureProps {
|
export interface ISubmitFeatureProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
@@ -43,7 +44,15 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({ context, activeCategoryI
|
|||||||
|
|
||||||
const requests = getRequestsByCategory(activeCategoryId);
|
const requests = getRequestsByCategory(activeCategoryId);
|
||||||
|
|
||||||
const handleBack = (): void => setSelectedRequest(null);
|
// Возврат к плиткам закрывает форму и теряет введённое (раздел остаётся
|
||||||
|
// смонтированным, но состояние формы живёт в самом FormComponent). Спрашиваем,
|
||||||
|
// если правки есть и они не сохранены; после согласия флаг снимаем — терять
|
||||||
|
// уже нечего.
|
||||||
|
const handleBack = (): void => {
|
||||||
|
if (!confirmDiscardFormChanges()) return;
|
||||||
|
markFormClean();
|
||||||
|
setSelectedRequest(null);
|
||||||
|
};
|
||||||
|
|
||||||
const renderSelected = (request: IRequestType): React.ReactNode => {
|
const renderSelected = (request: IRequestType): React.ReactNode => {
|
||||||
const FormComponent = getFormComponent(request.id);
|
const FormComponent = getFormComponent(request.id);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useState, useMemo, useCallback } from 'react';
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
import styles from './RecruitingHireForm.module.scss';
|
import styles from './RecruitingHireForm.module.scss';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -20,21 +20,26 @@ import {
|
|||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
IApproverToWrite
|
IApproverToWrite,
|
||||||
|
APPROVAL_PHASE,
|
||||||
|
findUnassignedStep
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { uploadAttachments } from '../../shared/attachments/attachments.service';
|
import { uploadAttachments } from '../../shared/attachments/attachments.service';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
import { IFormData, EMPTY_DATA, SetField } from './model';
|
import { IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
import { saveHireRequest, hireApprovalService, HIRE_REQUEST_TYPE_ID, DATA_LIST_TITLE } from './submit';
|
import { saveHireRequest, hireApprovalService, HIRE_REQUEST_TYPE_ID, DATA_LIST_TITLE } from './submit';
|
||||||
|
import { loadHireRequest } from './load';
|
||||||
import { ApplicationSection } from './sections/ApplicationSection';
|
import { ApplicationSection } from './sections/ApplicationSection';
|
||||||
import { CandidateSection } from './sections/CandidateSection';
|
import { CandidateSection } from './sections/CandidateSection';
|
||||||
import { PpeSection } from './sections/PpeSection';
|
import { PpeSection } from './sections/PpeSection';
|
||||||
import { ConditionsSection } from './sections/ConditionsSection';
|
import { ConditionsSection } from './sections/ConditionsSection';
|
||||||
|
|
||||||
const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved }) => {
|
const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, editId }) => {
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
const [requestId, setRequestId] = useState<number | undefined>(undefined);
|
// При открытии на редактирование (editId) — сразу известен, иначе появится
|
||||||
|
// после первого сохранения.
|
||||||
|
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
|
||||||
@@ -42,25 +47,68 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
|
|
||||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
|
// Открыта СВОЯ заявка на редактирование — подгружаем её содержимое и статус.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let alive = true;
|
||||||
|
loadHireRequest(editId)
|
||||||
|
.then((loaded) => {
|
||||||
|
if (alive) {
|
||||||
|
setData(loaded.data);
|
||||||
|
setRequestStatus(loaded.status);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (alive) {
|
||||||
|
submit.setError(`Не удалось загрузить заявку №${editId}: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [editId]);
|
||||||
|
|
||||||
// Маршрут согласования (планируемый) резолвится по email инициатора — текущего
|
// Маршрут согласования (планируемый) резолвится по email инициатора — текущего
|
||||||
// пользователя (кандидата ещё нет в системе).
|
// пользователя (кандидата ещё нет в системе). У заявки, открытой на
|
||||||
|
// редактирование, маршрут читается из уже записанных строк — планирование
|
||||||
|
// затёрло бы правки, сделанные при первом сохранении.
|
||||||
const approval = useMemo(() => hireApprovalService(), []);
|
const approval = useMemo(() => hireApprovalService(), []);
|
||||||
const userEmail = context.pageContext.user.email;
|
const userEmail = context.pageContext.user.email;
|
||||||
const loadMatrix = useCallback(
|
const loadMatrix = useCallback(
|
||||||
() => getPlannedMatrix(context, HIRE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'),
|
() =>
|
||||||
[context, userEmail, approval]
|
editId
|
||||||
|
? approval
|
||||||
|
.getMatrixForRequest(editId)
|
||||||
|
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution))
|
||||||
|
: getPlannedMatrix(context, HIRE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'),
|
||||||
|
[context, userEmail, approval, editId]
|
||||||
);
|
);
|
||||||
const matrix = useMatrixEditor(loadMatrix, userEmail, !readOnly, true, (login) =>
|
const matrix = useMatrixEditor(
|
||||||
approval.getJobTitle(login)
|
loadMatrix,
|
||||||
|
`${userEmail}-${editId ?? 'new'}`,
|
||||||
|
!readOnly,
|
||||||
|
true,
|
||||||
|
(login) => approval.getJobTitle(login)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Фаза исполнения: исполнители из маршрута (status='Execution'), только для чтения.
|
// Фаза исполнения: исполнители из маршрута (status='Execution'), только для чтения.
|
||||||
const loadExecutors = useCallback(
|
const loadExecutors = useCallback(
|
||||||
() => getPlannedMatrix(context, HIRE_REQUEST_TYPE_ID, userEmail, approval, 'Execution'),
|
() =>
|
||||||
[context, userEmail, approval]
|
editId
|
||||||
|
? approval
|
||||||
|
.getMatrixForRequest(editId)
|
||||||
|
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution))
|
||||||
|
: getPlannedMatrix(context, HIRE_REQUEST_TYPE_ID, userEmail, approval, 'Execution'),
|
||||||
|
[context, userEmail, approval, editId]
|
||||||
);
|
);
|
||||||
const executors = useMatrixEditor(loadExecutors, userEmail, false, true, (login) =>
|
const executors = useMatrixEditor(
|
||||||
approval.getJobTitle(login)
|
loadExecutors,
|
||||||
|
`${userEmail}-${editId ?? 'new'}`,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
(login) => approval.getJobTitle(login)
|
||||||
);
|
);
|
||||||
|
|
||||||
const isValid = data.CandidateName.trim().length > 0 && data.Position.trim().length > 0;
|
const isValid = data.CandidateName.trim().length > 0 && data.Position.trim().length > 0;
|
||||||
@@ -68,7 +116,14 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
isValid ? undefined : 'Заполните обязательные поля: Ф.И.О. кандидата, Должность.';
|
isValid ? undefined : 'Заполните обязательные поля: Ф.И.О. кандидата, Должность.';
|
||||||
|
|
||||||
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
||||||
rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign }));
|
// order обязателен: параллельные шаги делят один номер, по позиции в
|
||||||
|
// массиве его не восстановить (см. useMatrixEditor/withOrders).
|
||||||
|
rows.map((r) => ({
|
||||||
|
email: r.email,
|
||||||
|
position: r.position,
|
||||||
|
allowReassign: r.allowReassign,
|
||||||
|
order: r.order,
|
||||||
|
}));
|
||||||
|
|
||||||
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
||||||
const id = await saveHireRequest(
|
const id = await saveHireRequest(
|
||||||
@@ -89,10 +144,28 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// На отправку (в отличие от черновика) маршрут должен быть полностью
|
||||||
|
// укомплектован — иначе шаг без назначенного человека молча пропадёт при
|
||||||
|
// записи в SharePoint (createSteps не может записать Person-поле без
|
||||||
|
// значения), и согласующие в середине маршрута просто не увидят заявку.
|
||||||
|
const validateSubmit = (): string | undefined => {
|
||||||
|
const formError = validate();
|
||||||
|
if (formError) return formError;
|
||||||
|
const unassigned = findUnassignedStep(matrix.rows);
|
||||||
|
if (unassigned) {
|
||||||
|
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`;
|
||||||
|
}
|
||||||
|
const unassignedExecutor = findUnassignedStep(executors.rows);
|
||||||
|
if (unassignedExecutor) {
|
||||||
|
return `Шаг ${unassignedExecutor.order} исполнения («${unassignedExecutor.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveDraft = (): Promise<void> =>
|
const handleSaveDraft = (): Promise<void> =>
|
||||||
submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||||
const handleSubmit = (): Promise<void> =>
|
const handleSubmit = (): Promise<void> =>
|
||||||
submit.run('submit', save(REQUEST_STATUS.onApproval), validate);
|
submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
|
||||||
|
|
||||||
const sectionProps = { data, set, context };
|
const sectionProps = { data, set, context };
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { navigate } from '../../shared/routing';
|
|||||||
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||||
import { IFormData, EMPTY_DATA, SetField } from './model';
|
import { IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
import { loadHireRequest } from './load';
|
import { loadHireRequest } from './load';
|
||||||
|
import { confirmDiscardFormChanges } from '../../shared/unsavedChanges';
|
||||||
import { hireApprovalService, DATA_LIST_TITLE } from './submit';
|
import { hireApprovalService, DATA_LIST_TITLE } from './submit';
|
||||||
import { ApplicationSection } from './sections/ApplicationSection';
|
import { ApplicationSection } from './sections/ApplicationSection';
|
||||||
import { CandidateSection } from './sections/CandidateSection';
|
import { CandidateSection } from './sections/CandidateSection';
|
||||||
@@ -121,9 +122,13 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
navigate({ mode: 'create', nav: 'new', formKey: WORKPLACE_FORM_KEY, sourceId: itemId })
|
// Раздел «Новая заявка» остаётся смонтированным при переключении
|
||||||
}
|
// вкладок, но переход «на основе» пересоздаёт форму — если там
|
||||||
|
// остались несохранённые правки, спрашиваем перед их потерей.
|
||||||
|
if (!confirmDiscardFormChanges()) return;
|
||||||
|
navigate({ mode: 'create', nav: 'new', formKey: WORKPLACE_FORM_KEY, sourceId: itemId });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Icon name="copy" size="sm" />
|
<Icon name="copy" size="sm" />
|
||||||
Организация рабочего места на основе этой
|
Организация рабочего места на основе этой
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ import {
|
|||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
IApproverToWrite
|
IApproverToWrite,
|
||||||
|
APPROVAL_PHASE,
|
||||||
|
findUnassignedStep
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { uploadAttachments } from '../../shared/attachments/attachments.service';
|
import { uploadAttachments } from '../../shared/attachments/attachments.service';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
@@ -32,14 +34,16 @@ import {
|
|||||||
DATA_LIST_TITLE
|
DATA_LIST_TITLE
|
||||||
} from './submit';
|
} from './submit';
|
||||||
import { loadPrefillFromHire } from './prefill';
|
import { loadPrefillFromHire } from './prefill';
|
||||||
|
import { loadWorkplaceRequest } from './load';
|
||||||
import { EmployeeSection } from './sections/EmployeeSection';
|
import { EmployeeSection } from './sections/EmployeeSection';
|
||||||
import { EquipmentSection } from './sections/EquipmentSection';
|
import { EquipmentSection } from './sections/EquipmentSection';
|
||||||
|
|
||||||
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, sourceId }) => {
|
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, sourceId, editId }) => {
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
// Id и статус текущей заявки: заполняются после первого сохранения.
|
// Id и статус текущей заявки: заполняются после первого сохранения, а при
|
||||||
const [requestId, setRequestId] = useState<number | undefined>(undefined);
|
// открытии на редактирование (editId) — сразу из загруженной записи.
|
||||||
|
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
|
||||||
@@ -48,6 +52,31 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
|
|
||||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
|
// Открыта СВОЯ заявка на редактирование — подгружаем её содержимое и статус.
|
||||||
|
// От предзаполнения «на основе» (ниже) отличается тем, что это та же запись:
|
||||||
|
// сохранение уйдёт в неё, а не создаст новую.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let alive = true;
|
||||||
|
loadWorkplaceRequest(editId)
|
||||||
|
.then((loaded) => {
|
||||||
|
if (alive) {
|
||||||
|
setData(loaded.data);
|
||||||
|
setRequestStatus(loaded.status);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (alive) {
|
||||||
|
submit.setError(`Не удалось загрузить заявку №${editId}: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [editId]);
|
||||||
|
|
||||||
// Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource).
|
// Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sourceId) {
|
if (!sourceId) {
|
||||||
@@ -72,19 +101,42 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
|
|
||||||
const approval = useMemo(() => workplaceApprovalService(), []);
|
const approval = useMemo(() => workplaceApprovalService(), []);
|
||||||
const userEmail = context.pageContext.user.email;
|
const userEmail = context.pageContext.user.email;
|
||||||
|
// У новой заявки маршрут ПЛАНИРУЕТСЯ (resolve по внешнему API), у открытой на
|
||||||
|
// редактирование — читается из уже записанных строк: их могли поправить
|
||||||
|
// вручную при первом сохранении, и планирование затёрло бы эти правки.
|
||||||
const loadMatrix = useCallback(
|
const loadMatrix = useCallback(
|
||||||
() => getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'),
|
() =>
|
||||||
[context, userEmail, approval]
|
editId
|
||||||
|
? approval
|
||||||
|
.getMatrixForRequest(editId)
|
||||||
|
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution))
|
||||||
|
: getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'),
|
||||||
|
[context, userEmail, approval, editId]
|
||||||
|
);
|
||||||
|
const matrix = useMatrixEditor(
|
||||||
|
loadMatrix,
|
||||||
|
`${userEmail}-${editId ?? 'new'}`,
|
||||||
|
!readOnly,
|
||||||
|
true,
|
||||||
|
(login) => approval.getJobTitle(login)
|
||||||
);
|
);
|
||||||
const matrix = useMatrixEditor(loadMatrix, userEmail, !readOnly, true, (login) => approval.getJobTitle(login));
|
|
||||||
|
|
||||||
// Фаза исполнения: исполнители из маршрута (status='Execution'), только для чтения.
|
// Фаза исполнения: исполнители из маршрута (status='Execution'), только для чтения.
|
||||||
const loadExecutors = useCallback(
|
const loadExecutors = useCallback(
|
||||||
() => getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Execution'),
|
() =>
|
||||||
[context, userEmail, approval]
|
editId
|
||||||
|
? approval
|
||||||
|
.getMatrixForRequest(editId)
|
||||||
|
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution))
|
||||||
|
: getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Execution'),
|
||||||
|
[context, userEmail, approval, editId]
|
||||||
);
|
);
|
||||||
const executors = useMatrixEditor(loadExecutors, userEmail, false, true, (login) =>
|
const executors = useMatrixEditor(
|
||||||
approval.getJobTitle(login)
|
loadExecutors,
|
||||||
|
`${userEmail}-${editId ?? 'new'}`,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
(login) => approval.getJobTitle(login)
|
||||||
);
|
);
|
||||||
|
|
||||||
const isValid = data.FullName.trim().length > 0 && data.Position.trim().length > 0;
|
const isValid = data.FullName.trim().length > 0 && data.Position.trim().length > 0;
|
||||||
@@ -92,7 +144,14 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
isValid ? undefined : 'Заполните обязательные поля: Ф.И.О., Должность.';
|
isValid ? undefined : 'Заполните обязательные поля: Ф.И.О., Должность.';
|
||||||
|
|
||||||
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
||||||
rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign }));
|
// order обязателен: параллельные шаги делят один номер, по позиции в
|
||||||
|
// массиве его не восстановить (см. useMatrixEditor/withOrders).
|
||||||
|
rows.map((r) => ({
|
||||||
|
email: r.email,
|
||||||
|
position: r.position,
|
||||||
|
allowReassign: r.allowReassign,
|
||||||
|
order: r.order,
|
||||||
|
}));
|
||||||
|
|
||||||
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
||||||
const id = await saveWorkplaceRequest(
|
const id = await saveWorkplaceRequest(
|
||||||
@@ -114,10 +173,28 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// На отправку (в отличие от черновика) маршрут должен быть полностью
|
||||||
|
// укомплектован — иначе шаг без назначенного человека молча пропадёт при
|
||||||
|
// записи в SharePoint (createSteps не может записать Person-поле без
|
||||||
|
// значения), и согласующие в середине маршрута просто не увидят заявку.
|
||||||
|
const validateSubmit = (): string | undefined => {
|
||||||
|
const formError = validate();
|
||||||
|
if (formError) return formError;
|
||||||
|
const unassigned = findUnassignedStep(matrix.rows);
|
||||||
|
if (unassigned) {
|
||||||
|
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`;
|
||||||
|
}
|
||||||
|
const unassignedExecutor = findUnassignedStep(executors.rows);
|
||||||
|
if (unassignedExecutor) {
|
||||||
|
return `Шаг ${unassignedExecutor.order} исполнения («${unassignedExecutor.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveDraft = (): Promise<void> =>
|
const handleSaveDraft = (): Promise<void> =>
|
||||||
submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||||
const handleSubmit = (): Promise<void> =>
|
const handleSubmit = (): Promise<void> =>
|
||||||
submit.run('submit', save(REQUEST_STATUS.onApproval), validate);
|
submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
|
||||||
|
|
||||||
const sectionProps = { data, set, context };
|
const sectionProps = { data, set, context };
|
||||||
|
|
||||||
|
|||||||
@@ -37,4 +37,11 @@ export interface IRequestFormProps {
|
|||||||
* Формы, не поддерживающие производные заявки, игнорируют.
|
* Формы, не поддерживающие производные заявки, игнорируют.
|
||||||
*/
|
*/
|
||||||
sourceId?: number;
|
sourceId?: number;
|
||||||
|
/**
|
||||||
|
* Id СВОЕЙ заявки, открытой на редактирование (черновик / возвращённая /
|
||||||
|
* отклонённая). В отличие от sourceId это не предзаполнение новой заявки, а
|
||||||
|
* та же самая запись: сохранение обновляет её, а не создаёт новую, и маршрут
|
||||||
|
* согласования читается из уже записанных строк, а не планируется заново.
|
||||||
|
*/
|
||||||
|
editId?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,11 +63,28 @@ export class ApprovalListService {
|
|||||||
phase: string = APPROVAL_PHASE.approval,
|
phase: string = APPROVAL_PHASE.approval,
|
||||||
orderStart: number = 1
|
orderStart: number = 1
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const withOrder = approvers
|
// orderStart — СМЕЩЕНИЕ фазы: у согласующих оно 1, у исполнителей начинается
|
||||||
.map((a, i) => ({ a, order: a.order ?? orderStart + i }))
|
// после последнего согласующего (обе фазы живут в одном списке и делят
|
||||||
.filter((x) => !!x.a.email);
|
// сквозную нумерацию). a.order — номер шага ВНУТРИ фазы (1..N), и у
|
||||||
|
// параллельных шагов он совпадает, поэтому нумеровать по позиции в массиве
|
||||||
|
// нельзя. Без a.order падаем на позицию — для вызовов, где параллельности нет.
|
||||||
|
const withOrder = approvers.map((a, i) => ({ a, order: orderStart - 1 + (a.order ?? i + 1) }));
|
||||||
|
|
||||||
for (const { a, order } of withOrder) {
|
// Строку без email в SharePoint не записать (Person-поле обязательного
|
||||||
|
// значения) — раньше такие шаги пропадали отсюда молча, и согласующие в
|
||||||
|
// середине маршрута просто не получали заявку (см. findUnassignedStep —
|
||||||
|
// это должно было быть отловлено валидацией ДО вызова createSteps; лог
|
||||||
|
// здесь — на случай, если её обошли, например прямым вызовом сервиса).
|
||||||
|
const missing = withOrder.filter((x) => !x.a.email);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
console.error(
|
||||||
|
`[Согласование] createSteps: пропущены шаги без назначенного человека (order ${missing
|
||||||
|
.map((x) => x.order)
|
||||||
|
.join(', ')}) — заявка №${requestId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { a, order } of withOrder.filter((x) => !!x.a.email)) {
|
||||||
const approverId = await this.resolveUserId(a.email);
|
const approverId = await this.resolveUserId(a.email);
|
||||||
await this.list().items.add({
|
await this.list().items.add({
|
||||||
Title: a.position || a.email,
|
Title: a.position || a.email,
|
||||||
@@ -165,6 +182,12 @@ export class ApprovalListService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Строки согласования для отображения матрицы (ФИО, должность, статус, дата, комментарий).
|
// Строки согласования для отображения матрицы (ФИО, должность, статус, дата, комментарий).
|
||||||
|
//
|
||||||
|
// Approver/EMail нужен не только для показа: при редактировании черновика
|
||||||
|
// (см. useMatrixEditor с editable=true в WorkplaceSetupForm/RecruitingHireForm)
|
||||||
|
// это ЕДИНСТВЕННЫЙ источник email для уже существующих строк — без него
|
||||||
|
// findUnassignedStep решает, что назначенного человека нет, хотя он есть
|
||||||
|
// (баг: пропущенный email тут выглядел так же, как реально пустой шаг).
|
||||||
public async getMatrixForRequest(requestId: number): Promise<IMatrixRow[]> {
|
public async getMatrixForRequest(requestId: number): Promise<IMatrixRow[]> {
|
||||||
const rows = (await this.list()
|
const rows = (await this.list()
|
||||||
.items.filter(`${FIELDS.ticket}Id eq ${requestId}`)
|
.items.filter(`${FIELDS.ticket}Id eq ${requestId}`)
|
||||||
@@ -177,17 +200,18 @@ export class ApprovalListService {
|
|||||||
FIELDS.order,
|
FIELDS.order,
|
||||||
FIELDS.allowReassign,
|
FIELDS.allowReassign,
|
||||||
FIELDS.phase,
|
FIELDS.phase,
|
||||||
`${FIELDS.approver}/Title`
|
`${FIELDS.approver}/Title`,
|
||||||
|
`${FIELDS.approver}/EMail`
|
||||||
)
|
)
|
||||||
.expand(FIELDS.approver)
|
.expand(FIELDS.approver)
|
||||||
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
|
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
return rows.map((r, i) => {
|
return rows.map((r, i) => {
|
||||||
const approver = r[FIELDS.approver] as { Title?: string } | undefined;
|
const approver = r[FIELDS.approver] as { Title?: string; EMail?: string } | undefined;
|
||||||
return {
|
return {
|
||||||
order: (r[FIELDS.order] as number) ?? i + 1,
|
order: (r[FIELDS.order] as number) ?? i + 1,
|
||||||
who: approver?.Title || '',
|
who: approver?.Title || '',
|
||||||
email: '',
|
email: approver?.EMail || '',
|
||||||
position: (r.Title as string) || '',
|
position: (r.Title as string) || '',
|
||||||
status: (r[FIELDS.status] as string) || '',
|
status: (r[FIELDS.status] as string) || '',
|
||||||
date: this.formatDate((r[FIELDS.date] as string) || ''),
|
date: this.formatDate((r[FIELDS.date] as string) || ''),
|
||||||
|
|||||||
@@ -76,13 +76,24 @@ export async function writeRoute(
|
|||||||
const submitted = status === REQUEST_STATUS.onApproval;
|
const submitted = status === REQUEST_STATUS.onApproval;
|
||||||
const rowStatus = submitted ? REQUEST_STATUS.queued : status;
|
const rowStatus = submitted ? REQUEST_STATUS.queued : status;
|
||||||
|
|
||||||
|
// Смещение фазы исполнения — по МАКСИМАЛЬНОМУ номеру шага согласующих, а не по
|
||||||
|
// их количеству: при параллельных шагах несколько согласующих делят один номер,
|
||||||
|
// поэтому approvers.length больше последнего реального шага (три согласующих,
|
||||||
|
// из которых двое параллельны, — это шаги 1 и 2, а не 1..3). На очередь это не
|
||||||
|
// влияет (activateNextQueued берёт минимальный order среди ждущих, разрывы ему
|
||||||
|
// безразличны) — но нумерация шагов в таблице «Исполнение» шла бы с пропуском.
|
||||||
|
const lastApprovalStep = approvers.reduce(
|
||||||
|
(max, a, i) => Math.max(max, a.order ?? i + 1),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
await approval.createSteps(requestId, approvers, rowStatus, APPROVAL_PHASE.approval, 1);
|
await approval.createSteps(requestId, approvers, rowStatus, APPROVAL_PHASE.approval, 1);
|
||||||
await approval.createSteps(
|
await approval.createSteps(
|
||||||
requestId,
|
requestId,
|
||||||
executors,
|
executors,
|
||||||
rowStatus,
|
rowStatus,
|
||||||
APPROVAL_PHASE.execution,
|
APPROVAL_PHASE.execution,
|
||||||
approvers.length + 1
|
lastApprovalStep + 1
|
||||||
);
|
);
|
||||||
|
|
||||||
if (submitted) {
|
if (submitted) {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export type { IApproverToWrite } from './approvalList.service';
|
|||||||
export { ApproverService } from './approverDecision.service';
|
export { ApproverService } from './approverDecision.service';
|
||||||
export type { ApproverDecision } from './approverDecision.service';
|
export type { ApproverDecision } from './approverDecision.service';
|
||||||
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
|
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
|
||||||
export { useMatrixEditor } from './useMatrixEditor';
|
export { useMatrixEditor, findUnassignedStep } from './useMatrixEditor';
|
||||||
export type { IUseMatrixEditor } from './useMatrixEditor';
|
export type { IUseMatrixEditor } from './useMatrixEditor';
|
||||||
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
||||||
export { UserPicker } from './ui/UserPicker';
|
export { UserPicker } from './ui/UserPicker';
|
||||||
|
|||||||
@@ -9,4 +9,10 @@ export interface IMatrixRow {
|
|||||||
comment: string;
|
comment: string;
|
||||||
allowReassign: boolean; // может ли этот согласующий добавить делеганта
|
allowReassign: boolean; // может ли этот согласующий добавить делеганта
|
||||||
phase?: string; // фаза строки: «Согласование» | «Исполнение» (см. APPROVAL_PHASE)
|
phase?: string; // фаза строки: «Согласование» | «Исполнение» (см. APPROVAL_PHASE)
|
||||||
|
// Шаг идёт ОДНОВРЕМЕННО с предыдущим (у обоих одинаковый order — так
|
||||||
|
// activateNextQueued активирует их вместе). У первой строки всегда false.
|
||||||
|
// При загрузке выводится из совпадения order с предыдущей строкой, при
|
||||||
|
// редактировании — переключается кнопкой и заново раздаёт order (см.
|
||||||
|
// useMatrixEditor/withOrders).
|
||||||
|
parallelWithPrev?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,23 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
emptyText = 'Согласующие не заданы.',
|
emptyText = 'Согласующие не заданы.',
|
||||||
pickerPlaceholder = 'Добавить согласующего: начните вводить ФИО…',
|
pickerPlaceholder = 'Добавить согласующего: начните вводить ФИО…',
|
||||||
}) => {
|
}) => {
|
||||||
const { rows, loading, error, editable } = editor;
|
const { rows, loading, error, editable, blocks } = editor;
|
||||||
|
|
||||||
|
// Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку
|
||||||
|
// «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже.
|
||||||
|
const blockByRow: Array<{ index: number; start: number; size: number }> = [];
|
||||||
|
blocks.forEach((b, bi) => {
|
||||||
|
for (let i = b.start; i <= b.end; i += 1) {
|
||||||
|
blockByRow[i] = { index: bi, start: b.start, size: b.end - b.start + 1 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Пустые колонки не показываем — столбец из одних прочерков только шумит.
|
||||||
|
// Условия РАЗНЫЕ, потому что заполняются эти поля в разные моменты: статус
|
||||||
|
// есть у строки сразу (у черновика — «Черновик»), а дата появляется только
|
||||||
|
// вместе с решением согласующего. У черновика поэтому виден статус, но не дата.
|
||||||
|
const hasStatuses = rows.some((r) => r.status && r.status !== '—');
|
||||||
|
const hasDates = rows.some((r) => !!r.date);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.matrixWrap}>
|
<div className={styles.matrixWrap}>
|
||||||
@@ -42,40 +58,76 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
<table className={styles.matrix}>
|
<table className={styles.matrix}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th className={styles.matrixStepCol}>Шаг</th>
|
||||||
<th>{roleLabel}</th>
|
<th>{roleLabel}</th>
|
||||||
<th>Должность</th>
|
<th>Должность</th>
|
||||||
<th>Статус</th>
|
{hasStatuses && <th>Статус</th>}
|
||||||
<th>Дата</th>
|
{hasDates && <th>Дата</th>}
|
||||||
|
{editable && <th />}
|
||||||
{editable && <th />}
|
{editable && <th />}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((r, i) => (
|
{rows.map((r, i) => {
|
||||||
<tr key={i}>
|
const block = blockByRow[i];
|
||||||
|
// Ячейка «Шаг» рисуется один раз на весь шаг и растягивается на его
|
||||||
|
// строки. Кнопки перемещения живут в ней же: двигать параллельные
|
||||||
|
// строки поодиночке бессмысленно — шаг переезжает целиком.
|
||||||
|
const isBlockStart = block.start === i;
|
||||||
|
return (
|
||||||
|
<tr key={i} className={block.size > 1 ? styles.parallelRow : undefined}>
|
||||||
|
{isBlockStart && (
|
||||||
|
<td className={styles.matrixStepCol} rowSpan={block.size}>
|
||||||
|
<span className={styles.stepNo}>{r.order}</span>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
<td>{r.who}</td>
|
<td>{r.who}</td>
|
||||||
<td>{r.position}</td>
|
<td>{r.position}</td>
|
||||||
|
{hasStatuses && (
|
||||||
<td>{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}</td>
|
<td>{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}</td>
|
||||||
<td>{r.date}</td>
|
)}
|
||||||
{editable && (
|
{hasDates && <td>{r.date}</td>}
|
||||||
<td className={styles.matrixActions}>
|
{/* Перемещение действует на ШАГ целиком (параллельные строки
|
||||||
|
переезжают вместе), поэтому стрелки живут в отдельной
|
||||||
|
объединённой по вертикали ячейке: одна пара на весь шаг,
|
||||||
|
по центру его строк. */}
|
||||||
|
{editable && isBlockStart && (
|
||||||
|
<td className={styles.matrixMoveCol} rowSpan={block.size}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.iconBtn}
|
className={styles.iconBtn}
|
||||||
disabled={i === 0}
|
disabled={block.index === 0}
|
||||||
onClick={() => editor.moveUp(i)}
|
onClick={() => editor.moveBlockUp(i)}
|
||||||
title="Выше"
|
title="Шаг выше"
|
||||||
>
|
>
|
||||||
<Icon name="chevron-down" size="sm" className={styles.flipY} />
|
<Icon name="chevron-down" size="sm" className={styles.flipY} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.iconBtn}
|
className={styles.iconBtn}
|
||||||
disabled={i === rows.length - 1}
|
disabled={block.index === blocks.length - 1}
|
||||||
onClick={() => editor.moveDown(i)}
|
onClick={() => editor.moveBlockDown(i)}
|
||||||
title="Ниже"
|
title="Шаг ниже"
|
||||||
>
|
>
|
||||||
<Icon name="chevron-down" size="sm" />
|
<Icon name="chevron-down" size="sm" />
|
||||||
</button>
|
</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{editable && (
|
||||||
|
<td className={styles.matrixActions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.iconBtn} ${r.parallelWithPrev ? styles.iconBtnActive : ''}`}
|
||||||
|
disabled={i === 0}
|
||||||
|
onClick={() => editor.toggleParallel(i)}
|
||||||
|
title={
|
||||||
|
r.parallelWithPrev
|
||||||
|
? 'Отцепить: выполнять после предыдущего шага'
|
||||||
|
: 'Объединить: выполнять одновременно с предыдущим шагом'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon name="user-check" size="sm" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
|
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
|
||||||
@@ -87,7 +139,8 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -113,16 +113,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ВАЖНО: не display:flex — это сняло бы с ячейки display:table-cell, и она
|
||||||
|
// перестала бы вставать в строку рядом с соседней (кнопки уезжали на вторую
|
||||||
|
// строку). Ячейка остаётся табличной, кнопки держатся в строке через nowrap.
|
||||||
.matrixActions {
|
.matrixActions {
|
||||||
display: flex;
|
white-space: nowrap;
|
||||||
gap: $sp-1;
|
text-align: right;
|
||||||
justify-content: flex-end;
|
vertical-align: middle;
|
||||||
|
|
||||||
|
// Отступ между любыми соседними элементами: кроме кнопок тут бывают распорки
|
||||||
|
// (см. .iconBtnSpacer), на которые селектор .iconBtn + .iconBtn не сработал бы.
|
||||||
|
> * + * {
|
||||||
|
margin-left: $sp-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Стрелки перемещения шага. Ячейка объединена по вертикали (rowSpan) на все
|
||||||
|
// строки шага, поэтому пара кнопок стоит по центру блока, а не у его первой
|
||||||
|
// строки. Как и .matrixActions — остаётся табличной ячейкой (см. комментарий
|
||||||
|
// там), кнопки держатся в строке через nowrap.
|
||||||
|
.matrixMoveCol {
|
||||||
|
width: 1%; // ужимает колонку по содержимому, ширину забирают текстовые колонки
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
vertical-align: middle;
|
||||||
|
|
||||||
|
> * + * {
|
||||||
|
margin-left: $sp-1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconBtn {
|
.iconBtn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
// Инлайновые кнопки иначе выравниваются по baseline и «прыгают» относительно
|
||||||
|
// кнопок соседней ячейки.
|
||||||
|
vertical-align: middle;
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -151,6 +178,41 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Включённый режим-переключатель (шаг помечен как параллельный) — в отличие от
|
||||||
|
// остальных кнопок это состояние, а не разовое действие, поэтому оно залито.
|
||||||
|
.iconBtnActive {
|
||||||
|
border-color: $gold-500;
|
||||||
|
background: $gold-500;
|
||||||
|
color: $navy-950;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: $gold-600;
|
||||||
|
background: $gold-600;
|
||||||
|
color: $navy-950;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Колонка с номером шага. Объединяется по вертикали (rowSpan) на все строки
|
||||||
|
// одного шага, поэтому содержимое центрируем — иначе номер и кнопки прижимались
|
||||||
|
// бы к верхней строке блока.
|
||||||
|
.matrixStepCol {
|
||||||
|
width: 48px;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepNo {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строки, входящие в шаг из нескольких параллельных согласующих — лёгкая
|
||||||
|
// заливка, чтобы блок читался как единое целое (границы ячейки «Шаг» его уже
|
||||||
|
// объединяют, но без фона строки визуально распадаются).
|
||||||
|
.parallelRow > td {
|
||||||
|
background: rgba($gold-500, .06);
|
||||||
|
}
|
||||||
|
|
||||||
.flipY {
|
.flipY {
|
||||||
transform: scaleY(-1);
|
transform: scaleY(-1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,96 @@ export interface IUseMatrixEditor {
|
|||||||
error: string | undefined;
|
error: string | undefined;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
addApprover: (user: ISiteUser) => void;
|
addApprover: (user: ISiteUser) => void;
|
||||||
moveUp: (index: number) => void;
|
/** Переместить ШАГ целиком (вместе со всеми параллельными строками) выше/ниже. */
|
||||||
moveDown: (index: number) => void;
|
moveBlockUp: (index: number) => void;
|
||||||
|
moveBlockDown: (index: number) => void;
|
||||||
remove: (index: number) => void;
|
remove: (index: number) => void;
|
||||||
|
/** Переключить «идёт параллельно с предыдущим шагом». Для первой строки — no-op. */
|
||||||
|
toggleParallel: (index: number) => void;
|
||||||
|
/** Границы шагов: индексы строк, объединённых общим order. */
|
||||||
|
blocks: IMatrixBlock[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function swap(rows: IMatrixRow[], a: number, b: number): IMatrixRow[] {
|
/** Один шаг маршрута: одна строка либо несколько параллельных (общий order). */
|
||||||
const next = [...rows];
|
export interface IMatrixBlock {
|
||||||
[next[a], next[b]] = [next[b], next[a]];
|
start: number;
|
||||||
return next;
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разбивает строки на шаги: новый шаг начинается там, где нет флага
|
||||||
|
// parallelWithPrev. Используется и для перемещения (двигаем шаг целиком), и для
|
||||||
|
// отрисовки (одна ячейка «Шаг» на все строки шага, см. ApprovalMatrix/rowSpan).
|
||||||
|
export function blockRanges(rows: IMatrixRow[]): IMatrixBlock[] {
|
||||||
|
const ranges: IMatrixBlock[] = [];
|
||||||
|
rows.forEach((r, i) => {
|
||||||
|
if (i === 0 || !r.parallelWithPrev) ranges.push({ start: i, end: i });
|
||||||
|
else ranges[ranges.length - 1].end = i;
|
||||||
|
});
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Индекс шага, которому принадлежит строка (-1 — не найдено).
|
||||||
|
function blockIndexOf(ranges: IMatrixBlock[], row: number): number {
|
||||||
|
for (let i = 0; i < ranges.length; i += 1) {
|
||||||
|
if (row >= ranges[i].start && row <= ranges[i].end) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Меняет местами два СОСЕДНИХ шага целиком. Флаги parallelWithPrev внутри шага
|
||||||
|
// остаются верными сами собой: у первой строки шага флаг снят, у остальных —
|
||||||
|
// стоит, а порядок строк внутри шага не меняется.
|
||||||
|
function swapBlocks(rows: IMatrixRow[], a: IMatrixBlock, b: IMatrixBlock): IMatrixRow[] {
|
||||||
|
return [
|
||||||
|
...rows.slice(0, a.start),
|
||||||
|
...rows.slice(b.start, b.end + 1),
|
||||||
|
...rows.slice(a.start, a.end + 1),
|
||||||
|
...rows.slice(b.end + 1),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Раздаёт order по флагу parallelWithPrev: у параллельных шагов order общий с
|
||||||
|
// предыдущим, у последовательных — на единицу больше. Вызывается после КАЖДОГО
|
||||||
|
// изменения состава/порядка, иначе order разъедется с тем, что видно на экране.
|
||||||
|
// Первая строка параллельной быть не может — ей всегда достаётся order 1.
|
||||||
|
function withOrders(rows: IMatrixRow[]): IMatrixRow[] {
|
||||||
|
let order = 0;
|
||||||
|
return rows.map((r, i) => {
|
||||||
|
const parallel = i > 0 && !!r.parallelWithPrev;
|
||||||
|
if (!parallel) order += 1;
|
||||||
|
return { ...r, order, parallelWithPrev: parallel };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Статус для ТОЛЬКО ЧТО ДОБАВЛЕННОЙ строки. Если у всех уже имеющихся строк
|
||||||
|
// статус одинаковый (маршрут целиком в одном состоянии — например, черновик),
|
||||||
|
// новая получает тот же: при сохранении writeRoute всё равно проставит всем
|
||||||
|
// строкам общий статус, и показывать «—» рядом с «Черновик» было бы враньём.
|
||||||
|
// Если статусы разные (возвращённая заявка, часть шагов уже с решениями) —
|
||||||
|
// честнее оставить «—»: в какое состояние попадёт новый шаг, ещё не решено.
|
||||||
|
function statusForNewRow(rows: IMatrixRow[]): string {
|
||||||
|
if (rows.length === 0) return '—';
|
||||||
|
const first = rows[0].status || '—';
|
||||||
|
return rows.every((r) => (r.status || '—') === first) ? first : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обратное преобразование для загруженных строк: одинаковый order у соседей
|
||||||
|
// означает параллельный шаг.
|
||||||
|
function markParallel(rows: IMatrixRow[]): IMatrixRow[] {
|
||||||
|
return rows.map((r, i) => ({
|
||||||
|
...r,
|
||||||
|
parallelWithPrev: i > 0 && r.order === rows[i - 1].order,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Первый шаг без назначенного человека (email пуст) — обычно шаг маршрута,
|
||||||
|
// который внешний approval-route API вернул как роль без привязанного
|
||||||
|
// исполнителя. Писать такую строку в SharePoint нельзя (Person-поле требует
|
||||||
|
// значение) — approvalList.service.ts/createSteps молча пропускает её при
|
||||||
|
// записи, поэтому перед отправкой это нужно поймать здесь и не дать заявке уйти
|
||||||
|
// на согласование с пропущенным шагом (см. RecruitingHireForm/WorkplaceSetupForm).
|
||||||
|
export function findUnassignedStep(rows: IMatrixRow[]): IMatrixRow | undefined {
|
||||||
|
return rows.filter((r) => !r.email).sort((a, b) => a.order - b.order)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,7 +126,7 @@ export function useMatrixEditor(
|
|||||||
load()
|
load()
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setRows(r);
|
setRows(markParallel(r));
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
@@ -65,16 +146,17 @@ export function useMatrixEditor(
|
|||||||
setRows((prev) => {
|
setRows((prev) => {
|
||||||
if (user.email && prev.some((r) => r.email === user.email)) return prev;
|
if (user.email && prev.some((r) => r.email === user.email)) return prev;
|
||||||
const row: IMatrixRow = {
|
const row: IMatrixRow = {
|
||||||
order: prev.length + 1,
|
order: 0, // проставит withOrders
|
||||||
who: user.name,
|
who: user.name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
position,
|
position,
|
||||||
status: '—',
|
status: statusForNewRow(prev),
|
||||||
date: '',
|
date: '',
|
||||||
comment: '',
|
comment: '',
|
||||||
allowReassign: false,
|
allowReassign: false,
|
||||||
|
parallelWithPrev: false,
|
||||||
};
|
};
|
||||||
return [...prev, row];
|
return withOrders([...prev, row]);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,10 +167,42 @@ export function useMatrixEditor(
|
|||||||
.catch(() => appendUser(user, ''));
|
.catch(() => appendUser(user, ''));
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveUp = (i: number): void => setRows((prev) => (i <= 0 ? prev : swap(prev, i, i - 1)));
|
const moveBlockUp = (i: number): void =>
|
||||||
const moveDown = (i: number): void =>
|
setRows((prev) => {
|
||||||
setRows((prev) => (i >= prev.length - 1 ? prev : swap(prev, i, i + 1)));
|
const ranges = blockRanges(prev);
|
||||||
const remove = (i: number): void => setRows((prev) => prev.filter((_, idx) => idx !== i));
|
const bi = blockIndexOf(ranges, i);
|
||||||
|
if (bi <= 0) return prev;
|
||||||
|
return withOrders(swapBlocks(prev, ranges[bi - 1], ranges[bi]));
|
||||||
|
});
|
||||||
|
|
||||||
return { rows, loading, error, editable, addApprover, moveUp, moveDown, remove };
|
const moveBlockDown = (i: number): void =>
|
||||||
|
setRows((prev) => {
|
||||||
|
const ranges = blockRanges(prev);
|
||||||
|
const bi = blockIndexOf(ranges, i);
|
||||||
|
if (bi < 0 || bi >= ranges.length - 1) return prev;
|
||||||
|
return withOrders(swapBlocks(prev, ranges[bi], ranges[bi + 1]));
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = (i: number): void =>
|
||||||
|
setRows((prev) => withOrders(prev.filter((_, idx) => idx !== i)));
|
||||||
|
|
||||||
|
const toggleParallel = (i: number): void =>
|
||||||
|
setRows((prev) =>
|
||||||
|
i <= 0
|
||||||
|
? prev
|
||||||
|
: withOrders(prev.map((r, idx) => (idx === i ? { ...r, parallelWithPrev: !r.parallelWithPrev } : r)))
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
editable,
|
||||||
|
addApprover,
|
||||||
|
moveBlockUp,
|
||||||
|
moveBlockDown,
|
||||||
|
remove,
|
||||||
|
toggleParallel,
|
||||||
|
blocks: blockRanges(rows),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ import * as React from 'react';
|
|||||||
// перезагрузки страницы. useRoute() подписан и на popstate (кнопка «назад»),
|
// перезагрузки страницы. useRoute() подписан и на popstate (кнопка «назад»),
|
||||||
// и на её собственное событие, поэтому реагирует на оба способа смены URL.
|
// и на её собственное событие, поэтому реагирует на оба способа смены URL.
|
||||||
|
|
||||||
export type PortalMode = 'submit' | 'approve' | 'create';
|
// 'edit' — открыть СВОЮ незавершённую заявку (черновик / возвращённую /
|
||||||
|
// отклонённую) на редактирование в той же выезжающей панели, что и просмотр.
|
||||||
|
export type PortalMode = 'submit' | 'approve' | 'create' | 'edit';
|
||||||
|
|
||||||
/** Раздел бокового меню. */
|
/** Раздел бокового меню. */
|
||||||
export type NavSection = 'new' | 'approvals' | 'own' | 'search';
|
export type NavSection = 'new' | 'approvals' | 'own' | 'search';
|
||||||
@@ -29,9 +31,9 @@ export interface IRoute {
|
|||||||
mode: PortalMode;
|
mode: PortalMode;
|
||||||
/** Раздел бокового меню; сохраняется и когда поверх открыта заявка. */
|
/** Раздел бокового меню; сохраняется и когда поверх открыта заявка. */
|
||||||
nav: NavSection;
|
nav: NavSection;
|
||||||
/** Ключ формы (тип заявки) — для 'approve' и 'create'. */
|
/** Ключ формы (тип заявки) — для 'approve', 'edit' и 'create'. */
|
||||||
formKey?: string;
|
formKey?: string;
|
||||||
/** Id заявки в списке данных — для 'approve'. */
|
/** Id заявки в списке данных — для 'approve' и 'edit'. */
|
||||||
itemId?: number;
|
itemId?: number;
|
||||||
/** Id исходной заявки для предзаполнения — для 'create' (напр. орг. места на основе прием). */
|
/** Id исходной заявки для предзаполнения — для 'create' (напр. орг. места на основе прием). */
|
||||||
sourceId?: number;
|
sourceId?: number;
|
||||||
@@ -59,6 +61,10 @@ export function parseRoute(search: string = window.location.search): IRoute {
|
|||||||
if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) {
|
if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) {
|
||||||
return { mode: 'approve', nav, formKey, itemId };
|
return { mode: 'approve', nav, formKey, itemId };
|
||||||
}
|
}
|
||||||
|
// Редактирование собственной незавершённой заявки — та же панель, но с формой.
|
||||||
|
if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) {
|
||||||
|
return { mode: 'edit', nav, formKey, itemId };
|
||||||
|
}
|
||||||
// Создание производной заявки с предзаполнением из исходной.
|
// Создание производной заявки с предзаполнением из исходной.
|
||||||
if (formKey && sourceId && !isNaN(sourceId)) {
|
if (formKey && sourceId && !isNaN(sourceId)) {
|
||||||
return { mode: 'create', nav: 'new', formKey, sourceId };
|
return { mode: 'create', nav: 'new', formKey, sourceId };
|
||||||
@@ -78,8 +84,8 @@ function buildQuery(route: IRoute): string {
|
|||||||
if (route.nav !== 'new') {
|
if (route.nav !== 'new') {
|
||||||
params.set('rpnav', route.nav);
|
params.set('rpnav', route.nav);
|
||||||
}
|
}
|
||||||
if (route.mode === 'approve' && route.formKey && route.itemId) {
|
if ((route.mode === 'approve' || route.mode === 'edit') && route.formKey && route.itemId) {
|
||||||
params.set('rpmode', 'approve');
|
params.set('rpmode', route.mode);
|
||||||
params.set('rptype', route.formKey);
|
params.set('rptype', route.formKey);
|
||||||
params.set('rpid', String(route.itemId));
|
params.set('rpid', String(route.itemId));
|
||||||
} else if (route.mode === 'create' && route.formKey && route.sourceId) {
|
} else if (route.mode === 'create' && route.formKey && route.sourceId) {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Поверх всего, включая SPFx-хром вебпарта — иначе падение внутри вложенного
|
||||||
|
// блока схлопывает только его, а не видно, что вообще произошло.
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 999999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: $sp-6;
|
||||||
|
background: rgba($navy-950, .78);
|
||||||
|
font-family: $font-sans;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 780px;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: $sp-6;
|
||||||
|
border-radius: $r-lg;
|
||||||
|
background: $white;
|
||||||
|
box-shadow: $sh-modal;
|
||||||
|
border-top: 4px solid $red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: $font-serif;
|
||||||
|
font-size: $fs-form-title;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source {
|
||||||
|
margin-top: $sp-1;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: $sp-4 0 0;
|
||||||
|
padding: $sp-3;
|
||||||
|
overflow: auto;
|
||||||
|
background: $gray-50;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
font-family: 'Consolas', 'Menlo', monospace;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
color: $gray-900;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: $sp-2;
|
||||||
|
margin-top: $sp-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyBtn {
|
||||||
|
@include btn-accent;
|
||||||
|
padding: 9px 16px;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reloadBtn {
|
||||||
|
padding: 9px 16px;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $white;
|
||||||
|
color: $navy-800;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $gold-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './ErrorOverlay.module.scss';
|
||||||
|
|
||||||
|
interface ICaughtError {
|
||||||
|
message: string;
|
||||||
|
stack: string;
|
||||||
|
source: string; // откуда поймана: React-рендер, window.onerror, unhandledrejection
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IErrorOverlayState {
|
||||||
|
error: ICaughtError | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatError(err: unknown, source: string): ICaughtError {
|
||||||
|
const e = err instanceof Error ? err : new Error(typeof err === 'string' ? err : JSON.stringify(err));
|
||||||
|
return { message: e.message, stack: e.stack || '(нет стека)', source };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Копия message+stack в буфер обмена, чтобы кинуть разработчику. Ошибка
|
||||||
|
// самого копирования не должна маскировать исходную — просто игнорируем.
|
||||||
|
function copyToClipboard(text: string): void {
|
||||||
|
navigator.clipboard.writeText(text).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ловит падения, которые иначе схлопывают вебпарт в SPFx workbench:
|
||||||
|
* 1) React-рендер (componentDidCatch);
|
||||||
|
* 2) синхронные ошибки вне React (window.onerror);
|
||||||
|
* 3) необработанные rejected promise (unhandledrejection).
|
||||||
|
* Вместо пустого места рисует поверх всего оверлей с текстом ошибки и кнопкой
|
||||||
|
* «Скопировать», чтобы можно было переслать стек трейс.
|
||||||
|
*/
|
||||||
|
export class ErrorOverlay extends React.Component<React.PropsWithChildren<unknown>, IErrorOverlayState> {
|
||||||
|
public state: IErrorOverlayState = { error: undefined };
|
||||||
|
|
||||||
|
private onWindowError = (event: ErrorEvent): void => {
|
||||||
|
this.setState({ error: formatError(event.error || event.message, 'window.onerror') });
|
||||||
|
};
|
||||||
|
|
||||||
|
private onUnhandledRejection = (event: PromiseRejectionEvent): void => {
|
||||||
|
this.setState({ error: formatError(event.reason, 'unhandledrejection') });
|
||||||
|
};
|
||||||
|
|
||||||
|
public componentDidMount(): void {
|
||||||
|
window.addEventListener('error', this.onWindowError);
|
||||||
|
window.addEventListener('unhandledrejection', this.onUnhandledRejection);
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentWillUnmount(): void {
|
||||||
|
window.removeEventListener('error', this.onWindowError);
|
||||||
|
window.removeEventListener('unhandledrejection', this.onUnhandledRejection);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getDerivedStateFromError(err: unknown): IErrorOverlayState {
|
||||||
|
return { error: formatError(err, 'React render') };
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(): React.ReactNode {
|
||||||
|
const { error } = this.state;
|
||||||
|
if (!error) return this.props.children;
|
||||||
|
|
||||||
|
const fullText = `[${error.source}] ${error.message}\n\n${error.stack}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.overlay}>
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.title}>Необработанное исключение</div>
|
||||||
|
<div className={styles.source}>{error.source}</div>
|
||||||
|
<pre className={styles.stack}>{fullText}</pre>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="button" className={styles.copyBtn} onClick={() => copyToClipboard(fullText)}>
|
||||||
|
Скопировать
|
||||||
|
</button>
|
||||||
|
<button type="button" className={styles.reloadBtn} onClick={() => window.location.reload()}>
|
||||||
|
Перезагрузить страницу
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ export { PeopleField } from './PeopleField/PeopleField';
|
|||||||
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
|
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
|
||||||
export { FormModeProvider, useFormReadOnly } from './FormMode';
|
export { FormModeProvider, useFormReadOnly } from './FormMode';
|
||||||
export { Icon } from './Icon/Icon';
|
export { Icon } from './Icon/Icon';
|
||||||
|
export { ErrorOverlay } from './ErrorOverlay/ErrorOverlay';
|
||||||
export { SubmitActions } from './SubmitActions/SubmitActions';
|
export { SubmitActions } from './SubmitActions/SubmitActions';
|
||||||
export { RequestNumber } from './RequestNumber/RequestNumber';
|
export { RequestNumber } from './RequestNumber/RequestNumber';
|
||||||
export { PrintButton } from './PrintButton/PrintButton';
|
export { PrintButton } from './PrintButton/PrintButton';
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Есть ли в открытой форме «Новая заявка» правки, которые ещё не сохранены.
|
||||||
|
//
|
||||||
|
// Нужно, потому что раздел «Новая заявка» держится смонтированным при
|
||||||
|
// переключении вкладок (см. RequestPortal.tsx), и единственное, что реально
|
||||||
|
// пересоздаёт форму, — переход «создать на основе». Перед ним и спрашиваем,
|
||||||
|
// но только если терять действительно есть что.
|
||||||
|
//
|
||||||
|
// Флаг ставится по факту пользовательского ввода (событие input/change из
|
||||||
|
// раздела формы — см. RequestPortal), а снимается после сохранения: черновик
|
||||||
|
// тоже считается сохранением, форма зовёт onSaved и в обоих случаях
|
||||||
|
// («Сохранить черновик» и «Отправить»), см. forms/*/Form.tsx.
|
||||||
|
let dirty = false;
|
||||||
|
|
||||||
|
export function markFormDirty(): void {
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markFormClean(): void {
|
||||||
|
dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasUnsavedFormChanges(): boolean {
|
||||||
|
return dirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Спросить подтверждение, если несохранённые правки есть. true — можно
|
||||||
|
// продолжать (правок нет либо пользователь согласился их потерять).
|
||||||
|
export function confirmDiscardFormChanges(): boolean {
|
||||||
|
if (!dirty) return true;
|
||||||
|
return window.confirm(
|
||||||
|
'В открытой форме «Новая заявка» есть несохранённые изменения — они будут потеряны. Продолжить?'
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user