feat: Алтынай Бултурукова правит все поля заявок на подбор и приём на своём шаге
Tests / frontend-test (push) Successful in 1m14s

This commit is contained in:
Тимур Городецкий
2026-09-24 17:02:57 +05:00
parent 9b83c54491
commit 8cfb364f9c
8 changed files with 215 additions and 40 deletions
@@ -12,3 +12,36 @@
justify-content: flex-end;
margin-bottom: $sp-4;
}
// Полоса «вы можете редактировать все поля» для согласующего с полным правом —
// см. shared/approval/fullEditRights.ts (тот же приём, что в RecruitingHireView).
.reviewBar {
display: flex;
flex-wrap: wrap; // на узком экране кнопка уходит под подсказку
align-items: center;
gap: $sp-3;
margin-bottom: $sp-4;
padding: $sp-3 $sp-4;
border: 1px solid $gold-300;
border-radius: $r-md;
background: $gold-50;
}
.reviewHint {
flex: 1;
min-width: 0;
font-size: $fs-sm;
color: $navy-800;
}
.savedNote {
font-size: $fs-sm;
color: $green-600;
font-weight: 600;
}
.saveError {
font-size: $fs-sm;
color: $red-500;
font-weight: 600;
}
@@ -2,7 +2,7 @@ import * as React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './RecruitingSelectionView.module.scss';
import { Alert, FormCard, InitiatorLine, FormModeProvider, CollapseSection, FormField, AttachmentList, PrintButton } from '../../shared/ui';
import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, CollapseSection, FormField, AttachmentList, PrintButton } from '../../shared/ui';
import {
ApproverService,
DecisionPanel,
@@ -14,9 +14,11 @@ import {
isApprovedStatus,
resolveApproverOrgInfo,
canEditRoute,
canEditAllFields,
REQUEST_STATUS,
IMatrixRow,
APPROVAL_PHASE
APPROVAL_PHASE,
ApproverDecision
} from '../../shared/approval';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { effectiveUserEmail } from '../../shared/impersonation';
@@ -29,7 +31,8 @@ import {
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadSelectionRequest } from './load';
import { selectionApprovalService } from './submit';
import { selectionApprovalService, updateAllSelectionFields } from './submit';
import { missingRequiredFields } from './validation';
import { ApplicationSection } from './sections/ApplicationSection';
import { DepartmentSection } from './sections/DepartmentSection';
import { VacancySection } from './sections/VacancySection';
@@ -48,11 +51,14 @@ interface IRecruitingSelectionViewProps {
onCodeLoaded?: (code: string) => void;
}
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
const NOOP_SET: SetField = () => undefined;
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля заявки (кроме вложений).';
// Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же
// секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>.
// секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>,
// кроме согласующего из fullEditRights.ts на его активном шаге — он правит все поля
// (вложения — нет).
const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
@@ -70,6 +76,13 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
// привязано к активному шагу — не зависит от того, чья сейчас очередь.
const canRemoveApprover = canEditRoute(effectiveUserEmail(context));
const [routeError, setRouteError] = useState<string | undefined>(undefined);
// Все поля заявки правит на своём АКТИВНОМ шаге согласующий из
// shared/approval/fullEditRights.ts (сейчас — Алтынай Бултурукова).
const [fullEditable, setFullEditable] = useState<boolean>(false);
const [saveState, setSaveState] = useState<SaveState>('idle');
const [saveErrorMessage, setSaveErrorMessage] = useState<string>('');
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
// selectionApprovalService можно звать не опасаясь исключения.
@@ -105,6 +118,55 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
approval.getJobTitle(l), resolveApproverOrgInfo(context)
);
// Право на все поля — только на СВОЁМ активном шаге согласования. isDelegator
// прав не даёт: делегировавший не решает за делегата (см. DecisionPanel).
useEffect(() => {
let alive = true;
approverService
.getPendingRowForCurrentUser(itemId)
.then((pending) => {
if (!alive) return;
const isActiveApproval =
!!pending && !pending.isDelegator && pending.phase !== APPROVAL_PHASE.execution;
setFullEditable(isActiveApproval && canEditAllFields(effectiveUserEmail(context)));
})
.catch(() => {
if (alive) setFullEditable(false);
});
return () => {
alive = false;
};
// decisionSeq: после решения/делегирования активный шаг меняется — права пересчитываем.
}, [itemId, approverService, decisionSeq, context]);
const saveAllFields = async (): Promise<void> => {
if (!fullEditable) return;
await updateAllSelectionFields(itemId, data, listId);
};
const handleSaveFields = async (): Promise<void> => {
setSaveState('saving');
setSaveErrorMessage('');
try {
await saveAllFields();
setSaveState('saved');
} catch (e) {
setSaveErrorMessage(friendlyErrorMessage(e));
setSaveState('error');
}
};
// Правки сохраняются вместе с решением — иначе решение ушло бы со старыми
// данными. Согласовать с очищенным обязательным полем нельзя; вернуть/
// отклонить — можно (бросок отменяет решение, текст показывает DecisionPanel).
const handleBeforeDecide = async (decision: ApproverDecision): Promise<void> => {
if (decision === 'approve') {
const missing = missingRequiredFields(data);
if (missing.length > 0) throw new Error(`заполните обязательные поля: ${missing.join(', ')}.`);
}
await saveAllFields();
};
// Удалить ещё не решённый шаг маршрута (см. ApproverService.deleteQueuedStep).
const handleRemoveApprover = (row: IMatrixRow): void => {
if (!row.id) return;
@@ -152,7 +214,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
);
}
const sectionProps = { data, set: NOOP_SET, context };
const sectionProps = { data, set, context };
const notify = (): void => {
if (!requestTypeId) return;
@@ -169,6 +231,19 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
{isApprovedStatus(status) && <PrintButton />}
</div>
{fullEditable && (
<div className={`${styles.reviewBar} rp-no-print`}>
<span className={styles.reviewHint}>{FULL_EDIT_HINT}</span>
<Button variant="secondary" onClick={handleSaveFields} disabled={saveState === 'saving'}>
Сохранить
</Button>
{saveState === 'saved' && <span className={styles.savedNote}>Сохранено</span>}
{saveState === 'error' && (
<span className={styles.saveError}>{saveErrorMessage || 'Ошибка сохранения'}</span>
)}
</div>
)}
<div className="rp-print-root">
<FormCard
title="Заявка на подбор"
@@ -181,7 +256,8 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
}
headerRight={status ? <StatusBadge status={status} /> : undefined}
>
<FormModeProvider readOnly>
{/* Вложения ниже — вне этого блока: полное право открывает только поля. */}
<FormModeProvider readOnly={!fullEditable}>
<ApplicationSection {...sectionProps} />
<DepartmentSection {...sectionProps} />
<VacancySection {...sectionProps} />
@@ -223,6 +299,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
service={approverService}
requestId={itemId}
search={(q) => approval.searchUsers(q)}
onBeforeDecide={fullEditable ? handleBeforeDecide : undefined}
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
@@ -1,6 +1,6 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList';
import { FORM_ID, IFormData } from './model';
// Поля-люди (один человек): логин → ensureUser → Id в колонку <Field>Id.
@@ -85,6 +85,17 @@ async function applyPeople(item: Record<string, unknown>, data: IFormData): Prom
export type ISaveResult = ISaveItemResult;
/**
* Перезаписать ВСЕ поля заявки — для согласующего с полным правом правки
* (см. shared/approval/fullEditRights.ts). Статус, маршрут и вложения не
* трогаются: заявка остаётся на том же шаге согласования.
*/
export async function updateAllSelectionFields(id: number, data: IFormData, listId?: string): Promise<void> {
const item = buildScalarBody(data);
await applyPeople(item, data);
await resolveDataList(FORM_ID, listId).items.getById(id).update(item);
}
/**
* Сохранить заявку «на подбор»: содержимое + маршрут (согласующие + исполнители).
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.