обходной лист на исполнеии

This commit is contained in:
gorbenko.pavel
2026-08-18 12:46:33 +05:00
parent 4fee057f15
commit 5ad90bb7cb
4 changed files with 95 additions and 14 deletions
@@ -21,6 +21,7 @@ import {
REQUEST_STATUS, REQUEST_STATUS,
isEditableStatus, isEditableStatus,
IApproverToWrite, IApproverToWrite,
APPROVAL_PHASE,
findUnassignedStep findUnassignedStep
} from '../../shared/approval'; } from '../../shared/approval';
import { IRequestFormProps } from '../../models/types'; import { IRequestFormProps } from '../../models/types';
@@ -106,17 +107,42 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
const loadMatrix = useCallback( const loadMatrix = useCallback(
() => () =>
editId editId
? approval.getMatrixForRequest(editId) ? approval
.getMatrixForRequest(editId)
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution))
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval', null, [ : getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval', null, [
{ fieldName: 'Position', value: data.Position } { fieldName: 'Position', value: data.Position }
]), ]),
[context, approval, editId, data.Employee, data.Position, requestTypeId] [context, approval, editId, data.Employee, data.Position, requestTypeId]
); );
const matrixReady = !!editId || (!!data.Employee && !!requestTypeId);
const matrix = useMatrixEditor( const matrix = useMatrixEditor(
loadMatrix, loadMatrix,
`${data.Employee}-${editId ?? 'new'}-${data.Position}`, `${data.Employee}-${editId ?? 'new'}-${data.Position}`,
!readOnly, !readOnly,
!!editId || (!!data.Employee && !!requestTypeId), matrixReady,
(login) => approval.getJobTitle(login)
);
// Фаза исполнения: тот же маршрут (условие «IsManager» по data.Position — см.
// комментарий у loadMatrix выше), только 'Execution' вместо 'Approval'. У
// открытой на редактирование заявки — строки уже записаны, читаем как есть.
const loadExecutors = useCallback(
() =>
editId
? approval
.getMatrixForRequest(editId)
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution))
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Execution', null, [
{ fieldName: 'Position', value: data.Position }
]),
[context, approval, editId, data.Employee, data.Position, requestTypeId]
);
const executors = useMatrixEditor(
loadExecutors,
`e-${data.Employee}-${editId ?? 'new'}-${data.Position}`,
!readOnly,
matrixReady,
(login) => approval.getJobTitle(login) (login) => approval.getJobTitle(login)
); );
@@ -148,7 +174,14 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
if (!rtId) { if (!rtId) {
throw new Error('Тип заявки не определён — обновите страницу и повторите.'); throw new Error('Тип заявки не определён — обновите страницу и повторите.');
} }
const { id, code } = await saveClearanceRequest(requestId, rtId, data, toApprovers(matrix.rows), status); const { id, code } = await saveClearanceRequest(
requestId,
rtId,
data,
toApprovers(matrix.rows),
toApprovers(executors.rows),
status
);
setRequestId(id); setRequestId(id);
setRequestStatus(status); setRequestStatus(status);
if (code) { if (code) {
@@ -179,6 +212,10 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
if (unassigned) { if (unassigned) {
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`; return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`;
} }
const unassignedExecutor = findUnassignedStep(executors.rows);
if (unassignedExecutor) {
return `Шаг ${unassignedExecutor.order} исполнения («${unassignedExecutor.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`;
}
return undefined; return undefined;
}; };
@@ -220,6 +257,18 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} /> <ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
{/* Блок исполнителя показываем, только когда по маршруту реально есть
исполнители (условие «IsManager» и т.п. могло не назначить ни одного). */}
{matrixReady && !executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix
editor={executors}
search={(q) => approval.searchUsers(q)}
title="Исполнение (после согласования)"
roleLabel="Исполнитель"
emptyText="Исполнители не заданы по маршруту."
/>
)}
<SubmitActions <SubmitActions
submit={submit} submit={submit}
isValid={isValid} isValid={isValid}
@@ -55,13 +55,31 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
[approval] [approval]
); );
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в // Матрица маршрута из фактических строк заявки (read-only), раздельно по
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу. // фазам: согласование и исполнение — два отдельных блока в UI (см. ниже).
// decisionSeq в reloadKey: после решения строки на сервере меняются —
// перечитываем матрицу.
const [decisionSeq, setDecisionSeq] = useState<number>(0); const [decisionSeq, setDecisionSeq] = useState<number>(0);
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]); const loadApprovers = useCallback(
() =>
approval
.getMatrixForRequest(itemId)
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)),
[approval, itemId]
);
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
approval.getJobTitle(l) approval.getJobTitle(l)
); );
const loadExecutors = useCallback(
() =>
approval
.getMatrixForRequest(itemId)
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)),
[approval, itemId]
);
const executors = useMatrixEditor(loadExecutors, `e-${itemId}-${decisionSeq}`, false, true, (l) =>
approval.getJobTitle(l)
);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
@@ -196,6 +214,16 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
</FormCard> </FormCard>
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} /> <ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
{!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix
editor={executors}
search={(q) => approval.searchUsers(q)}
title="Исполнение"
roleLabel="Исполнитель"
emptyText="Исполнители не назначены."
/>
)}
</div> </div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
@@ -2,9 +2,9 @@ import * as React from 'react';
import { CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField } from '../../../shared/ui'; import { CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField } from '../../../shared/ui';
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model'; import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
// Должность/подразделение подтягиваются автоматически при выборе сотрудника // Подразделение подтягивается автоматически при выборе сотрудника (см.
// (см. ClearanceForm.handleEmployeeChange — SharePoint User Profile) — // ClearanceForm.handleEmployeeChange — SharePoint User Profile) — свободный
// свободный ввод здесь намеренно убран, чтобы не расходиться с профилем. // ввод здесь намеренно убран, чтобы не расходиться с профилем.
const NOOP = (): void => undefined; const NOOP = (): void => undefined;
interface IRequestSectionProps extends ISectionProps { interface IRequestSectionProps extends ISectionProps {
@@ -32,12 +32,16 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
placeholder={EMPLOYEE_PLACEHOLDER} placeholder={EMPLOYEE_PLACEHOLDER}
/> />
</FormField> </FormField>
{/* Подтягивается из профиля при выборе сотрудника, но поле остаётся
редактируемым: если профиль не синхронизирован (SPS-JobTitle пуст),
HR должен суметь ввести должность вручную — иначе маршрут
согласования (условие «IsManager» резолвится именно по ней) не
соберётся, и список исполнителей ниже останется пустым. */}
<FormField label="Должность" htmlFor="Position" required> <FormField label="Должность" htmlFor="Position" required>
<TextInput <TextInput
id="Position" id="Position"
value={data.Position} value={data.Position}
onChange={NOOP} onChange={(v) => set('Position', v)}
readOnly
placeholder="Подставится автоматически по выбранному сотруднику" placeholder="Подставится автоматически по выбранному сотруднику"
/> />
</FormField> </FormField>
@@ -76,9 +76,8 @@ export interface ISaveResult {
} }
/** /**
* Сохранить обходной лист: содержимое + маршрут согласования. * Сохранить обходной лист: содержимое + маршрут (согласование + исполнение).
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Фазы исполнения у обходного листа нет — writeRoute вызывается с пустым списком исполнителей.
* Возвращает Id заявки (+ код при создании). * Возвращает Id заявки (+ код при создании).
*/ */
export async function saveClearanceRequest( export async function saveClearanceRequest(
@@ -86,6 +85,7 @@ export async function saveClearanceRequest(
requestTypeId: number, requestTypeId: number,
data: IFormData, data: IFormData,
approvers: IApproverToWrite[], approvers: IApproverToWrite[],
executors: IApproverToWrite[],
status: string status: string
): Promise<ISaveResult> { ): Promise<ISaveResult> {
const list = getSP().web.lists.getByTitle(dataListTitle(FORM_ID)); const list = getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
@@ -105,6 +105,6 @@ export async function saveClearanceRequest(
await list.items.getById(id).update({ RequestUniqueCode: code }); await list.items.getById(id).update({ RequestUniqueCode: code });
} }
await writeRoute(clearanceApprovalService(), id, approvers, [], status); await writeRoute(clearanceApprovalService(), id, approvers, executors, status);
return { id, code }; return { id, code };
} }