Параллельные шаги #3
@@ -12,9 +12,12 @@ import { CATEGORIES, DEFAULT_CATEGORY_ID } from '../data/categories';
|
||||
import { REQUEST_TYPES } from '../data/requests';
|
||||
import SubmitFeature from '../features/submit/SubmitFeature';
|
||||
import ApproveFeature from '../features/approve/ApproveFeature';
|
||||
import EditFeature from '../features/edit/EditFeature';
|
||||
import { RequestsTableView } from '../features/myRequests/RequestsTableView';
|
||||
import { SearchView } from '../features/myRequests/SearchView';
|
||||
import { getPendingApproval } from '../features/myRequests/myRequests.service';
|
||||
import { IMyRequest } from '../features/myRequests/models/IMyRequest';
|
||||
import { isEditableStatus } from '../shared/approval';
|
||||
|
||||
// Оболочка портала: тёмное боковое меню (разделы) + основная область.
|
||||
// Логики фич тут нет — файл общий, меняется редко.
|
||||
@@ -99,8 +102,28 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
if (nav === route.nav && route.mode === 'submit') emitRequestsChanged();
|
||||
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 closeEditDrawer = (): void => {
|
||||
if (!confirmDiscardFormChanges()) return;
|
||||
markFormClean();
|
||||
closeDrawer();
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={rootClasses}>
|
||||
<IconSprite />
|
||||
@@ -158,14 +181,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
{route.nav === 'search' && (
|
||||
<SearchView
|
||||
context={context}
|
||||
onOpen={(request) =>
|
||||
navigate({
|
||||
mode: 'approve',
|
||||
nav: route.nav,
|
||||
formKey: request.formKey,
|
||||
itemId: request.id,
|
||||
})
|
||||
}
|
||||
onOpen={openRequest}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -173,14 +189,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
<RequestsTableView
|
||||
context={context}
|
||||
kind={route.nav === 'approvals' ? 'approvals' : 'own'}
|
||||
onOpen={(request) =>
|
||||
navigate({
|
||||
mode: 'approve',
|
||||
nav: route.nav,
|
||||
formKey: request.formKey,
|
||||
itemId: request.id,
|
||||
})
|
||||
}
|
||||
onOpen={openRequest}
|
||||
onMyTurnCountChange={route.nav === 'approvals' ? setApprovalsCount : undefined}
|
||||
/>
|
||||
)}
|
||||
@@ -192,6 +201,21 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
<ApproveFeature context={context} formKey={route.formKey} itemId={route.itemId} />
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -28,6 +28,11 @@ const RECENT_MS = 10 * 60 * 1000;
|
||||
// лимита пишем предупреждение в лог, а не молча обрезаем.
|
||||
const SEARCH_ROW_LIMIT = 500;
|
||||
|
||||
// Сколько последних заявок автора дочитываем через REST в довесок к Search
|
||||
// в разделе «Мои заявки» (см. getFreshOwnRowsForType). Это записи ОДНОГО
|
||||
// пользователя, поэтому лимит с запасом; при упоре в него пишем предупреждение.
|
||||
const OWN_REST_TOP = 200;
|
||||
|
||||
// Managed properties списка согласования. Имена включают группу колонок сайта
|
||||
// (TansuSite) — SharePoint генерирует их так, поэтому «ожидаемого»
|
||||
// ApproverOWSUSER в схеме поиска не находилось (разбирались 2026-08-06).
|
||||
@@ -513,14 +518,33 @@ async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestTyp
|
||||
const me = await sp.web.currentUser.select('Id')();
|
||||
const cutoff = new Date(Date.now() - RECENT_MS).toISOString();
|
||||
|
||||
// Фильтруем только по AuthorId, а «свежая или черновик» решаем в JS: статус
|
||||
// в $filter не используем, потому что кириллица в сравнении там уже подводила
|
||||
// (см. indexApprovalsForType). Берём последние OWN_REST_TOP заявок автора —
|
||||
// это его собственные записи, их немного, и запрос ограничен по объёму.
|
||||
const rows = (await sp.web
|
||||
.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')
|
||||
.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) {
|
||||
console.error(`[Текущие заявки] «${type.name}»: не удалось дочитать свежие заявки:`, (e as Error).message);
|
||||
return [];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
Alert,
|
||||
@@ -20,21 +20,26 @@ import {
|
||||
getPlannedMatrix,
|
||||
REQUEST_STATUS,
|
||||
isEditableStatus,
|
||||
IApproverToWrite
|
||||
IApproverToWrite,
|
||||
APPROVAL_PHASE,
|
||||
findUnassignedStep
|
||||
} from '../../shared/approval';
|
||||
import { uploadAttachments } from '../../shared/attachments/attachments.service';
|
||||
import { IRequestFormProps } from '../../models/types';
|
||||
import { IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { saveHireRequest, hireApprovalService, HIRE_REQUEST_TYPE_ID, DATA_LIST_TITLE } from './submit';
|
||||
import { loadHireRequest } from './load';
|
||||
import { ApplicationSection } from './sections/ApplicationSection';
|
||||
import { CandidateSection } from './sections/CandidateSection';
|
||||
import { PpeSection } from './sections/PpeSection';
|
||||
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 [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 submit = useRequestSubmit();
|
||||
|
||||
@@ -42,25 +47,68 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
|
||||
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 инициатора — текущего
|
||||
// пользователя (кандидата ещё нет в системе).
|
||||
// пользователя (кандидата ещё нет в системе). У заявки, открытой на
|
||||
// редактирование, маршрут читается из уже записанных строк — планирование
|
||||
// затёрло бы правки, сделанные при первом сохранении.
|
||||
const approval = useMemo(() => hireApprovalService(), []);
|
||||
const userEmail = context.pageContext.user.email;
|
||||
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) =>
|
||||
approval.getJobTitle(login)
|
||||
const matrix = useMatrixEditor(
|
||||
loadMatrix,
|
||||
`${userEmail}-${editId ?? 'new'}`,
|
||||
!readOnly,
|
||||
true,
|
||||
(login) => approval.getJobTitle(login)
|
||||
);
|
||||
|
||||
// Фаза исполнения: исполнители из маршрута (status='Execution'), только для чтения.
|
||||
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) =>
|
||||
approval.getJobTitle(login)
|
||||
const executors = useMatrixEditor(
|
||||
loadExecutors,
|
||||
`${userEmail}-${editId ?? 'new'}`,
|
||||
false,
|
||||
true,
|
||||
(login) => approval.getJobTitle(login)
|
||||
);
|
||||
|
||||
const isValid = data.CandidateName.trim().length > 0 && data.Position.trim().length > 0;
|
||||
@@ -96,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> =>
|
||||
submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||
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 };
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
getPlannedMatrix,
|
||||
REQUEST_STATUS,
|
||||
isEditableStatus,
|
||||
IApproverToWrite
|
||||
IApproverToWrite,
|
||||
APPROVAL_PHASE,
|
||||
findUnassignedStep
|
||||
} from '../../shared/approval';
|
||||
import { uploadAttachments } from '../../shared/attachments/attachments.service';
|
||||
import { IRequestFormProps } from '../../models/types';
|
||||
@@ -32,14 +34,16 @@ import {
|
||||
DATA_LIST_TITLE
|
||||
} from './submit';
|
||||
import { loadPrefillFromHire } from './prefill';
|
||||
import { loadWorkplaceRequest } from './load';
|
||||
import { EmployeeSection } from './sections/EmployeeSection';
|
||||
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 [files, setFiles] = useState<File[]>([]);
|
||||
// Id и статус текущей заявки: заполняются после первого сохранения.
|
||||
const [requestId, setRequestId] = useState<number | undefined>(undefined);
|
||||
// Id и статус текущей заявки: заполняются после первого сохранения, а при
|
||||
// открытии на редактирование (editId) — сразу из загруженной записи.
|
||||
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||
const submit = useRequestSubmit();
|
||||
|
||||
@@ -48,6 +52,31 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
|
||||
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).
|
||||
useEffect(() => {
|
||||
if (!sourceId) {
|
||||
@@ -72,19 +101,42 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
|
||||
const approval = useMemo(() => workplaceApprovalService(), []);
|
||||
const userEmail = context.pageContext.user.email;
|
||||
// У новой заявки маршрут ПЛАНИРУЕТСЯ (resolve по внешнему API), у открытой на
|
||||
// редактирование — читается из уже записанных строк: их могли поправить
|
||||
// вручную при первом сохранении, и планирование затёрло бы эти правки.
|
||||
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'), только для чтения.
|
||||
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) =>
|
||||
approval.getJobTitle(login)
|
||||
const executors = useMatrixEditor(
|
||||
loadExecutors,
|
||||
`${userEmail}-${editId ?? 'new'}`,
|
||||
false,
|
||||
true,
|
||||
(login) => approval.getJobTitle(login)
|
||||
);
|
||||
|
||||
const isValid = data.FullName.trim().length > 0 && data.Position.trim().length > 0;
|
||||
@@ -121,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> =>
|
||||
submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||
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 };
|
||||
|
||||
|
||||
@@ -37,4 +37,11 @@ export interface IRequestFormProps {
|
||||
* Формы, не поддерживающие производные заявки, игнорируют.
|
||||
*/
|
||||
sourceId?: number;
|
||||
/**
|
||||
* Id СВОЕЙ заявки, открытой на редактирование (черновик / возвращённая /
|
||||
* отклонённая). В отличие от sourceId это не предзаполнение новой заявки, а
|
||||
* та же самая запись: сохранение обновляет её, а не создаёт новую, и маршрут
|
||||
* согласования читается из уже записанных строк, а не планируется заново.
|
||||
*/
|
||||
editId?: number;
|
||||
}
|
||||
|
||||
@@ -68,11 +68,23 @@ export class ApprovalListService {
|
||||
// сквозную нумерацию). a.order — номер шага ВНУТРИ фазы (1..N), и у
|
||||
// параллельных шагов он совпадает, поэтому нумеровать по позиции в массиве
|
||||
// нельзя. Без a.order падаем на позицию — для вызовов, где параллельности нет.
|
||||
const withOrder = approvers
|
||||
.map((a, i) => ({ a, order: orderStart - 1 + (a.order ?? i + 1) }))
|
||||
.filter((x) => !!x.a.email);
|
||||
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);
|
||||
await this.list().items.add({
|
||||
Title: a.position || a.email,
|
||||
@@ -170,6 +182,12 @@ export class ApprovalListService {
|
||||
}
|
||||
|
||||
// Строки согласования для отображения матрицы (ФИО, должность, статус, дата, комментарий).
|
||||
//
|
||||
// Approver/EMail нужен не только для показа: при редактировании черновика
|
||||
// (см. useMatrixEditor с editable=true в WorkplaceSetupForm/RecruitingHireForm)
|
||||
// это ЕДИНСТВЕННЫЙ источник email для уже существующих строк — без него
|
||||
// findUnassignedStep решает, что назначенного человека нет, хотя он есть
|
||||
// (баг: пропущенный email тут выглядел так же, как реально пустой шаг).
|
||||
public async getMatrixForRequest(requestId: number): Promise<IMatrixRow[]> {
|
||||
const rows = (await this.list()
|
||||
.items.filter(`${FIELDS.ticket}Id eq ${requestId}`)
|
||||
@@ -182,17 +200,18 @@ export class ApprovalListService {
|
||||
FIELDS.order,
|
||||
FIELDS.allowReassign,
|
||||
FIELDS.phase,
|
||||
`${FIELDS.approver}/Title`
|
||||
`${FIELDS.approver}/Title`,
|
||||
`${FIELDS.approver}/EMail`
|
||||
)
|
||||
.expand(FIELDS.approver)
|
||||
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
|
||||
|
||||
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 {
|
||||
order: (r[FIELDS.order] as number) ?? i + 1,
|
||||
who: approver?.Title || '',
|
||||
email: '',
|
||||
email: approver?.EMail || '',
|
||||
position: (r.Title as string) || '',
|
||||
status: (r[FIELDS.status] as string) || '',
|
||||
date: this.formatDate((r[FIELDS.date] as string) || ''),
|
||||
|
||||
@@ -24,7 +24,7 @@ export type { IApproverToWrite } from './approvalList.service';
|
||||
export { ApproverService } from './approverDecision.service';
|
||||
export type { ApproverDecision } from './approverDecision.service';
|
||||
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
|
||||
export { useMatrixEditor } from './useMatrixEditor';
|
||||
export { useMatrixEditor, findUnassignedStep } from './useMatrixEditor';
|
||||
export type { IUseMatrixEditor } from './useMatrixEditor';
|
||||
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
||||
export { UserPicker } from './ui/UserPicker';
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
|
||||
.matrixActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -181,15 +182,16 @@
|
||||
|
||||
// Перемещение шага целиком — тоже объединённая по вертикали ячейка, поэтому
|
||||
// кнопки центрируем относительно всего блока.
|
||||
// Та же flex-раскладка, что и у .matrixActions — иначе кнопки здесь (обычные
|
||||
// инлайновые элементы) выравниваются по baseline и «прыгают» относительно
|
||||
// кнопок соседней колонки, которая уже flex-контейнер.
|
||||
.matrixBlockMove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: $sp-1;
|
||||
width: 64px;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
text-align: right;
|
||||
|
||||
.iconBtn + .iconBtn {
|
||||
margin-left: $sp-1;
|
||||
}
|
||||
}
|
||||
|
||||
// Строки, входящие в шаг из нескольких параллельных согласующих — лёгкая
|
||||
|
||||
@@ -78,6 +78,16 @@ function markParallel(rows: IMatrixRow[]): IMatrixRow[] {
|
||||
}));
|
||||
}
|
||||
|
||||
// Первый шаг без назначенного человека (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];
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает матрицу согласования и (если editable) позволяет её редактировать.
|
||||
* @param load загрузчик строк (планируемый маршрут из resolve или строки заявки).
|
||||
|
||||
@@ -20,7 +20,9 @@ import * as React from 'react';
|
||||
// перезагрузки страницы. useRoute() подписан и на popstate (кнопка «назад»),
|
||||
// и на её собственное событие, поэтому реагирует на оба способа смены URL.
|
||||
|
||||
export type PortalMode = 'submit' | 'approve' | 'create';
|
||||
// 'edit' — открыть СВОЮ незавершённую заявку (черновик / возвращённую /
|
||||
// отклонённую) на редактирование в той же выезжающей панели, что и просмотр.
|
||||
export type PortalMode = 'submit' | 'approve' | 'create' | 'edit';
|
||||
|
||||
/** Раздел бокового меню. */
|
||||
export type NavSection = 'new' | 'approvals' | 'own' | 'search';
|
||||
@@ -29,9 +31,9 @@ export interface IRoute {
|
||||
mode: PortalMode;
|
||||
/** Раздел бокового меню; сохраняется и когда поверх открыта заявка. */
|
||||
nav: NavSection;
|
||||
/** Ключ формы (тип заявки) — для 'approve' и 'create'. */
|
||||
/** Ключ формы (тип заявки) — для 'approve', 'edit' и 'create'. */
|
||||
formKey?: string;
|
||||
/** Id заявки в списке данных — для 'approve'. */
|
||||
/** Id заявки в списке данных — для 'approve' и 'edit'. */
|
||||
itemId?: number;
|
||||
/** Id исходной заявки для предзаполнения — для 'create' (напр. орг. места на основе прием). */
|
||||
sourceId?: number;
|
||||
@@ -59,6 +61,10 @@ export function parseRoute(search: string = window.location.search): IRoute {
|
||||
if (mode === 'approve' && formKey && itemId && !isNaN(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)) {
|
||||
return { mode: 'create', nav: 'new', formKey, sourceId };
|
||||
@@ -78,8 +84,8 @@ function buildQuery(route: IRoute): string {
|
||||
if (route.nav !== 'new') {
|
||||
params.set('rpnav', route.nav);
|
||||
}
|
||||
if (route.mode === 'approve' && route.formKey && route.itemId) {
|
||||
params.set('rpmode', 'approve');
|
||||
if ((route.mode === 'approve' || route.mode === 'edit') && route.formKey && route.itemId) {
|
||||
params.set('rpmode', route.mode);
|
||||
params.set('rptype', route.formKey);
|
||||
params.set('rpid', String(route.itemId));
|
||||
} else if (route.mode === 'create' && route.formKey && route.sourceId) {
|
||||
|
||||
Reference in New Issue
Block a user