fix: добавил блок исполнения в заявке на подбор
Tests / frontend-test (push) Successful in 1m11s

This commit is contained in:
Тимур Городецкий
2026-09-22 13:07:04 +05:00
parent 11e05c1786
commit f6dcf349e9
3 changed files with 78 additions and 10 deletions
@@ -28,7 +28,8 @@ import {
IApproverToWrite, IApproverToWrite,
unassignedStepMessage, unassignedStepMessage,
resolveApproverOrgInfo, resolveApproverOrgInfo,
canEditRoute canEditRoute,
APPROVAL_PHASE
} from '../../shared/approval'; } from '../../shared/approval';
import { import {
uploadAttachments, uploadAttachments,
@@ -200,7 +201,9 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
const loadMatrix = useCallback( const loadMatrix = useCallback(
() => () =>
editId editId
? approval.getMatrixForRequest(editId) ? approval
.getMatrixForRequest(editId)
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution))
: getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, fieldValues, projectId), : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, fieldValues, projectId),
[context, userEmail, approval, editId, requestTypeId, fieldValues, projectId] [context, userEmail, approval, editId, requestTypeId, fieldValues, projectId]
); );
@@ -214,6 +217,25 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
resolveApproverOrgInfo(context) resolveApproverOrgInfo(context)
); );
// Фаза исполнения: исполнители из маршрута (status='Execution'), только для чтения.
const loadExecutors = useCallback(
() =>
editId
? approval
.getMatrixForRequest(editId)
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution))
: getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Execution', null, fieldValues, projectId),
[context, userEmail, approval, editId, requestTypeId, fieldValues, projectId]
);
const executors = useMatrixEditor(
loadExecutors,
`${userEmail}-${editId ?? 'new'}-${projectId}-${companiesFieldValue}`,
false,
matrixReady,
(login) => approval.getJobTitle(login),
resolveApproverOrgInfo(context)
);
// ObjectProjectId обязателен (а не просто текст ObjectProject): маршрут // ObjectProjectId обязателен (а не просто текст ObjectProject): маршрут
// согласования резолвится ПО ПРОЕКТУ (ByProject, см. projectId выше), и без // согласования резолвится ПО ПРОЕКТУ (ByProject, см. projectId выше), и без
// выбранного из списка варианта (id) approval-route вернёт 404 — форма // выбранного из списка варианта (id) approval-route вернёт 404 — форма
@@ -284,6 +306,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
rtId, rtId,
data, data,
toApprovers(matrix.rows), toApprovers(matrix.rows),
toApprovers(executors.rows),
status, status,
writeListId writeListId
); );
@@ -338,6 +361,11 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.` (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
); );
if (unassignedError) return unassignedError; if (unassignedError) return unassignedError;
const unassignedExecutorError = unassignedStepMessage(
executors.rows,
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`
);
if (unassignedExecutorError) return unassignedExecutorError;
return undefined; return undefined;
}; };
@@ -407,6 +435,16 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
canRemoveRow={canRemoveApprover ? () => true : undefined} canRemoveRow={canRemoveApprover ? () => true : undefined}
/> />
{!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}
@@ -15,7 +15,8 @@ import {
resolveApproverOrgInfo, resolveApproverOrgInfo,
canEditRoute, canEditRoute,
REQUEST_STATUS, REQUEST_STATUS,
IMatrixRow IMatrixRow,
APPROVAL_PHASE
} from '../../shared/approval'; } from '../../shared/approval';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { effectiveUserEmail } from '../../shared/impersonation'; import { effectiveUserEmail } from '../../shared/impersonation';
@@ -79,13 +80,30 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
const dataListRef = listId || dataListTitle(FORM_ID); const dataListRef = listId || dataListTitle(FORM_ID);
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]); const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в // Матрица маршрута из фактических строк заявки (read-only), разбитая по
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу. // фазам. 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), resolveApproverOrgInfo(context) approval.getJobTitle(l), resolveApproverOrgInfo(context)
); );
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), resolveApproverOrgInfo(context)
);
// Удалить ещё не решённый шаг маршрута (см. ApproverService.deleteQueuedStep). // Удалить ещё не решённый шаг маршрута (см. ApproverService.deleteQueuedStep).
const handleRemoveApprover = (row: IMatrixRow): void => { const handleRemoveApprover = (row: IMatrixRow): void => {
@@ -187,6 +205,16 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
canRemoveRow={canRemoveApprover ? (row) => row.status === REQUEST_STATUS.queued : undefined} canRemoveRow={canRemoveApprover ? (row) => row.status === REQUEST_STATUS.queued : undefined}
onRemoveRow={handleRemoveApprover} onRemoveRow={handleRemoveApprover}
/> />
{!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix
editor={executors}
search={(q) => approval.searchUsers(q)}
title="Исполнение"
roleLabel="Исполнитель"
emptyText="Исполнители не назначены."
/>
)}
</div> </div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
@@ -86,10 +86,11 @@ async function applyPeople(item: Record<string, unknown>, data: IFormData): Prom
export type ISaveResult = ISaveItemResult; export type ISaveResult = ISaveItemResult;
/** /**
* Сохранить заявку «на подбор»: содержимое + маршрут согласования. * Сохранить заявку «на подбор»: содержимое + маршрут (согласующие + исполнители).
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* status = «Черновик» (сохранение) или «На согласовании» (отправка). Фазы исполнения * status = «Черновик» (сохранение) или «На согласовании» (отправка).
* нет — writeRoute вызывается с пустым списком исполнителей. Возвращает Id (+ код при создании). * executors может быть пустым — тогда после согласования заявка станет «Согласовано»
* (см. computeRequestStatus в approverDecision.service.ts). Возвращает Id (+ код при создании).
* *
* listId — партиция, в которую фактически пишем: при редактировании — партиция * listId — партиция, в которую фактически пишем: при редактировании — партиция
* ОБНОВЛЯЕМОЙ заявки (см. loadSelectionRequest), при создании — партиция, * ОБНОВЛЯЕМОЙ заявки (см. loadSelectionRequest), при создании — партиция,
@@ -104,6 +105,7 @@ export async function saveSelectionRequest(
requestTypeId: number, requestTypeId: number,
data: IFormData, data: IFormData,
approvers: IApproverToWrite[], approvers: IApproverToWrite[],
executors: IApproverToWrite[],
status: string, status: string,
listId?: string listId?: string
): Promise<ISaveResult> { ): Promise<ISaveResult> {
@@ -112,6 +114,6 @@ export async function saveSelectionRequest(
await applyPeople(item, data); await applyPeople(item, data);
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item); const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
await writeRoute(selectionApprovalService(listId), result.id, approvers, [], status); await writeRoute(selectionApprovalService(listId), result.id, approvers, executors, status);
return result; return result;
} }