Compare commits
2 Commits
2ade16ce1d
...
690a692c07
| Author | SHA1 | Date | |
|---|---|---|---|
| 690a692c07 | |||
| cfbca8c2b2 |
+3
-1
@@ -20,7 +20,9 @@
|
|||||||
| Удалять любых согласующих **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все остальные формы с маршрутом согласования (отпуск, увольнение, 1С и т. д.); в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `composeRemoveRowRule` |
|
| Удалять любых согласующих **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все остальные формы с маршрутом согласования (отпуск, увольнение, 1С и т. д.); в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `composeRemoveRowRule` |
|
||||||
| Добавлять и удалять исполнителей **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все формы, где в маршруте из админки есть фаза исполнения; в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `canEditExecutors` |
|
| Добавлять и удалять исполнителей **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все формы, где в маршруте из админки есть фаза исполнения; в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `canEditExecutors` |
|
||||||
| Заменять и удалять исполнителей, **в том числе активного** («На исполнении») | Алтынай Бултурукова | тот же email (`canEditRoute`) | **Только** Заявка на подбор, матрица «Исполнение» | `canEditRoute` + `forms/RecruitingSelection/RecruitingSelectionView.tsx` |
|
| Заменять и удалять исполнителей, **в том числе активного** («На исполнении») | Алтынай Бултурукова | тот же email (`canEditRoute`) | **Только** Заявка на подбор, матрица «Исполнение» | `canEditRoute` + `forms/RecruitingSelection/RecruitingSelectionView.tsx` |
|
||||||
| Править **все поля и вложения** заявки (добавлять и удалять файлы) на своём активном шаге согласования | Алтынай Бултурукова | email в коде | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` |
|
| Править **все поля и вложения** заявки (добавлять и удалять файлы) на своём активном шаге согласования | Алтынай Бултурукова | email в коде (список по каждой форме) | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` |
|
||||||
|
| То же: **все поля и вложения**, но **не только на своём шаге**: достаточно быть в маршруте (согласование или исполнение), пока заявка «На согласовании» или «Выполнение» | Ербол Шаймерденов | email в коде (список по каждой форме) + участие в маршруте | **Только** Организация рабочего места | `shared/approval/fullEditRights.ts` → `canEditAllFields` + `forms/WorkplaceSetup/useApproverRights.ts` |
|
||||||
|
| Заполнять «Необходимое ПО» и «Доступы» на своём шаге согласования | Первый шаг маршрута согласования (по маршруту — непосредственный руководитель) | порядок шага в маршруте | Организация рабочего места | `forms/WorkplaceSetup/useApproverRights.ts` (`canEditReviewFields`) |
|
||||||
| Править оклад на ИС, оклад после ИС, «Отдел/Участок» на своём шаге согласования | Начальник расчётной группы (сейчас Ким Анна) | должность шага: «начальник/руководитель» + «расчет» | Заявка на приём | `forms/RecruitingHire/approverRights.ts` → `canEditPayrollFields` |
|
| Править оклад на ИС, оклад после ИС, «Отдел/Участок» на своём шаге согласования | Начальник расчётной группы (сейчас Ким Анна) | должность шага: «начальник/руководитель» + «расчет» | Заявка на приём | `forms/RecruitingHire/approverRights.ts` → `canEditPayrollFields` |
|
||||||
| Ставить дату приёма (2.2) | Активный исполнитель | роль в маршруте (шаг «Исполнение»), без проверки должности | Заявка на приём | `forms/RecruitingHire/RecruitingHireView.tsx` (`hireDateEditable`) |
|
| Ставить дату приёма (2.2) | Активный исполнитель | роль в маршруте (шаг «Исполнение»), без проверки должности | Заявка на приём | `forms/RecruitingHire/RecruitingHireView.tsx` (`hireDateEditable`) |
|
||||||
| Править карту СКУД и сумму за обучение | Начальник отдела кадрового делопроизводства | должность шага: «начальник/руководитель» + «кадров» + «делопроизводств» | Обходной лист | `forms/Clearance/approverRights.ts` → `canEditHrRecordsFields` |
|
| Править карту СКУД и сумму за обучение | Начальник отдела кадрового делопроизводства | должность шага: «начальник/руководитель» + «кадров» + «делопроизводств» | Обходной лист | `forms/Clearance/approverRights.ts` → `canEditHrRecordsFields` |
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
const isExecution = pending?.phase === APPROVAL_PHASE.execution;
|
const isExecution = pending?.phase === APPROVAL_PHASE.execution;
|
||||||
setPayrollEditable(isActive && !isExecution && canEditPayrollFields(pending?.position));
|
setPayrollEditable(isActive && !isExecution && canEditPayrollFields(pending?.position));
|
||||||
setHireDateEditable(isActive && isExecution);
|
setHireDateEditable(isActive && isExecution);
|
||||||
setFullEditable(isActive && !isExecution && canEditAllFields(effectiveUserEmail(context)));
|
setFullEditable(isActive && !isExecution && canEditAllFields(FORM_ID, effectiveUserEmail(context)));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import {
|
|||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { getAttachments } from '../../shared/attachments/attachments.service';
|
import { getAttachments } from '../../shared/attachments/attachments.service';
|
||||||
import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
|
import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
|
||||||
import { SelectionAttachments } from './SelectionAttachments';
|
import { EditableAttachments } from '../../shared/attachments/EditableAttachments';
|
||||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
import {
|
import {
|
||||||
@@ -152,7 +152,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
const isActiveApproval =
|
const isActiveApproval =
|
||||||
!!pending && !pending.isDelegator && pending.phase !== APPROVAL_PHASE.execution;
|
!!pending && !pending.isDelegator && pending.phase !== APPROVAL_PHASE.execution;
|
||||||
setFullEditable(isActiveApproval && canEditAllFields(effectiveUserEmail(context)));
|
setFullEditable(isActiveApproval && canEditAllFields(FORM_ID, effectiveUserEmail(context)));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (alive) setFullEditable(false);
|
if (alive) setFullEditable(false);
|
||||||
@@ -348,7 +348,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
</FieldChangesProvider>
|
</FieldChangesProvider>
|
||||||
|
|
||||||
<div className="rp-no-print">
|
<div className="rp-no-print">
|
||||||
<SelectionAttachments editor={attachments} editable={fullEditable} />
|
<EditableAttachments editor={attachments} editable={fullEditable} />
|
||||||
</div>
|
</div>
|
||||||
</FormCard>
|
</FormCard>
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,18 @@
|
|||||||
- **Шаг 1 согласования — непосредственный руководитель** (роль в маршруте).
|
- **Шаг 1 согласования — непосредственный руководитель** (роль в маршруте).
|
||||||
|
|
||||||
## Особое поведение
|
## Особое поведение
|
||||||
- **Префилл «на основе» заявки на прием** (открывается по `rpsource`): копируются
|
- **Префилл из заявки на прием** — два входа: кнопка «на основе» в самой заявке на
|
||||||
ФИО, телефон, должность, отдел/участок, вид работ, местоположение, дата приёма
|
приём (`rpsource`) или поле «На основе заявки на приём» вверху формы
|
||||||
(`loadPrefillFromHire`). Это снимок на момент создания; ссылка на источник
|
(`HireRequestPicker`: поиск по утверждённым заявкам на приём во всех партициях —
|
||||||
сохраняется в колонке-lookup `SourceHire`.
|
для тех, кто чужую заявку на приём в портале не видит). Копируются ФИО, телефон,
|
||||||
|
должность, подразделение, местоположение, дата приёма (`hireLookup.ts` →
|
||||||
|
`loadHirePrefill`); из заявки на приём читаются только эти поля, оклады в браузер
|
||||||
|
не уходят. Это снимок на момент создания; ссылка на источник — в колонке-lookup
|
||||||
|
`SourceHire` (только для заявок из текущей партиции, см. `sourceHireLookupId`).
|
||||||
|
Нужно право чтения списка «Прием на работу — данные» в SharePoint.
|
||||||
|
- **Полное право правки** (`shared/approval/fullEditRights.ts`, сейчас Ербол
|
||||||
|
Шаймерденов): все поля и вложения, если он есть в маршруте и заявка «На
|
||||||
|
согласовании»/«Выполнение» (`useApproverRights.ts`).
|
||||||
- **Поля «Необходимое ПО» и «Доступы» заполняет согласующий-руководитель** на своём
|
- **Поля «Необходимое ПО» и «Доступы» заполняет согласующий-руководитель** на своём
|
||||||
этапе (инициатор их обычно не знает). Доступны для правки, когда очередь дошла до
|
этапе (инициатор их обычно не знает). Доступны для правки, когда очередь дошла до
|
||||||
**первого** шага согласования и это текущий пользователь; сохраняются кнопкой
|
**первого** шага согласования и это текущий пользователь; сохраняются кнопкой
|
||||||
@@ -24,7 +32,7 @@
|
|||||||
- Печать — общий механизм (без «Вложений»).
|
- Печать — общий механизм (без «Вложений»).
|
||||||
|
|
||||||
## Секции формы
|
## Секции формы
|
||||||
EmployeeSection · EquipmentSection
|
HireRequestPicker (только в редактируемой форме) · EmployeeSection · EquipmentSection
|
||||||
|
|
||||||
## Точки входа
|
## Точки входа
|
||||||
- rptype: `it-workplace-setup`
|
- rptype: `it-workplace-setup`
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ import {
|
|||||||
RequestNumber,
|
RequestNumber,
|
||||||
SubmitActions,
|
SubmitActions,
|
||||||
getDraftDeleteHandler,
|
getDraftDeleteHandler,
|
||||||
useRequestSubmit
|
useRequestSubmit,
|
||||||
|
FieldChangesProvider,
|
||||||
|
FieldChangesBanner
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
|
import { IFieldChange, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
|
||||||
import {
|
import {
|
||||||
composeRemoveRowRule,
|
composeRemoveRowRule,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
@@ -51,8 +54,10 @@ import {
|
|||||||
} from '../../shared/listTitles';
|
} from '../../shared/listTitles';
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
import { saveWorkplaceRequest, workplaceApprovalService } from './submit';
|
import { saveWorkplaceRequest, workplaceApprovalService } from './submit';
|
||||||
import { loadPrefillFromHire } from './prefill';
|
import { IHireRequestOption, loadHirePrefill, sourceHireLookupId } from './hireLookup';
|
||||||
|
import { HireRequestPicker } from './sections/HireRequestPicker';
|
||||||
import { loadWorkplaceRequest } from './load';
|
import { loadWorkplaceRequest } from './load';
|
||||||
|
import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation';
|
||||||
import { EmployeeSection } from './sections/EmployeeSection';
|
import { EmployeeSection } from './sections/EmployeeSection';
|
||||||
import { EquipmentSection } from './sections/EquipmentSection';
|
import { EquipmentSection } from './sections/EquipmentSection';
|
||||||
import { useStableCallback } from '../../shared/useStableCallback';
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
@@ -75,6 +80,8 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
|||||||
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
|
// Журнал правок согласующего — виден и здесь, когда заявку вернули на доработку.
|
||||||
|
const [approverChanges, setApproverChanges] = useState<IFieldChange[]>([]);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
||||||
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
||||||
@@ -102,6 +109,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
|||||||
.then(([loaded, atts]) => {
|
.then(([loaded, atts]) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
|
setApproverChanges(loaded.approverChanges);
|
||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
setRequestCode(loaded.code || undefined);
|
setRequestCode(loaded.code || undefined);
|
||||||
if (loaded.code) notifyCode(loaded.code);
|
if (loaded.code) notifyCode(loaded.code);
|
||||||
@@ -118,19 +126,25 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
|||||||
};
|
};
|
||||||
}, [editId, editListId, notifyCode, setSubmitError]);
|
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||||
|
|
||||||
|
// Заявка на приём, на основе которой создаётся эта: из кнопки «на основе» в
|
||||||
|
// самой заявке на приём (rpsource) или из поля выбора в форме (HireRequestPicker).
|
||||||
|
const [sourceHire, setSourceHire] = useState<IHireRequestOption | undefined>(
|
||||||
|
sourceId ? { id: sourceId, listId: '', code: '', candidateName: '', position: '' } : undefined
|
||||||
|
);
|
||||||
|
|
||||||
// Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource).
|
// Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sourceId) {
|
if (!sourceId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let alive = true;
|
let alive = true;
|
||||||
loadPrefillFromHire(sourceId)
|
loadHirePrefill(sourceId)
|
||||||
.then((prefilled) => {
|
.then((prefilled) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
// Merge, а не replace: организация заявителя (ниже) резолвится отдельным
|
// Merge, а не replace: организация заявителя (ниже) резолвится отдельным
|
||||||
// эффектом и могла успеть записаться раньше — не затираем её пустышкой
|
// эффектом и могла успеть записаться раньше — не затираем её.
|
||||||
// из prefilled (в loadPrefillFromHire её нет).
|
setData((prev) => ({ ...prev, ...prefilled }));
|
||||||
setData((prev) => ({ ...prefilled, Companies: prev.Companies }));
|
setSourceHire((prev) => (prev ? { ...prev, candidateName: prefilled.FullName } : prev));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@@ -219,13 +233,9 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
|||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login)
|
||||||
);
|
);
|
||||||
|
|
||||||
const isValid =
|
const isValid = hasRequiredFields(data);
|
||||||
data.FullName.trim().length > 0 &&
|
|
||||||
data.NameLatin.trim().length > 0 &&
|
|
||||||
data.Position.trim().length > 0 &&
|
|
||||||
data.Companies.length > 0;
|
|
||||||
const validate = (): string | undefined => {
|
const validate = (): string | undefined => {
|
||||||
if (!isValid) return 'Заполните обязательные поля: Ф.И.О., Фамилия и имя (латиница), Должность, Организация.';
|
if (!isValid) return REQUIRED_FIELDS_MESSAGE;
|
||||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -246,6 +256,17 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
|||||||
autoApprove: r.autoApprove
|
autoApprove: r.autoApprove
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Выбор заявки на приём в форме: поля из неё перезаписывают уже введённые —
|
||||||
|
// пользователь осознанно выбрал источник. Id отдела сбрасываем вместе с
|
||||||
|
// названием: в заявке на приём отдел без Id справочника, а старый Id от
|
||||||
|
// другого отдела увёл бы маршрут не туда.
|
||||||
|
const handlePickHire = (option: IHireRequestOption): void => {
|
||||||
|
setSourceHire(option);
|
||||||
|
loadHirePrefill(option.id, option.listId || undefined)
|
||||||
|
.then((prefilled) => setData((prev) => ({ ...prev, ...prefilled, DepartmentId: '' })))
|
||||||
|
.catch((e) => setSubmitError(`Не удалось заполнить из заявки на приём: ${friendlyErrorMessage(e)}`));
|
||||||
|
};
|
||||||
|
|
||||||
// Удаление уже прикреплённого файла (в правке) — сразу из заявки.
|
// Удаление уже прикреплённого файла (в правке) — сразу из заявки.
|
||||||
const handleRemoveAttachment = (fileName: string): void => {
|
const handleRemoveAttachment = (fileName: string): void => {
|
||||||
if (requestId === undefined) {
|
if (requestId === undefined) {
|
||||||
@@ -279,7 +300,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
|||||||
toApprovers(matrix.rows),
|
toApprovers(matrix.rows),
|
||||||
toApprovers(executors.rows),
|
toApprovers(executors.rows),
|
||||||
status,
|
status,
|
||||||
sourceId,
|
sourceHire ? sourceHireLookupId(sourceHire) : undefined,
|
||||||
writeListId
|
writeListId
|
||||||
);
|
);
|
||||||
setRequestId(id);
|
setRequestId(id);
|
||||||
@@ -378,10 +399,21 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
|||||||
)}
|
)}
|
||||||
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||||
|
|
||||||
<FormModeProvider readOnly={readOnly}>
|
{!readOnly && (
|
||||||
<EmployeeSection {...sectionProps} />
|
<HireRequestPicker
|
||||||
<EquipmentSection {...sectionProps} />
|
selected={sourceHire}
|
||||||
</FormModeProvider>
|
onPick={handlePickHire}
|
||||||
|
onClear={() => setSourceHire(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FieldChangesBanner changes={approverChanges} />
|
||||||
|
<FieldChangesProvider changes={toChangeMap(approverChanges)}>
|
||||||
|
<FormModeProvider readOnly={readOnly}>
|
||||||
|
<EmployeeSection {...sectionProps} />
|
||||||
|
<EquipmentSection {...sectionProps} />
|
||||||
|
</FormModeProvider>
|
||||||
|
</FieldChangesProvider>
|
||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<CollapseSection title="Вложения" icon="upload">
|
<CollapseSection title="Вложения" icon="upload">
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
.reviewBar {
|
.reviewBar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $sp-3;
|
gap: $sp-3;
|
||||||
margin-bottom: $sp-4;
|
margin-bottom: $sp-4;
|
||||||
|
|||||||
@@ -8,12 +8,11 @@ import {
|
|||||||
FormCard,
|
FormCard,
|
||||||
InitiatorLine,
|
InitiatorLine,
|
||||||
FormModeProvider,
|
FormModeProvider,
|
||||||
CollapseSection,
|
|
||||||
FormField,
|
|
||||||
AttachmentList,
|
|
||||||
PrintSplitButton,
|
PrintSplitButton,
|
||||||
OrgLetterhead,
|
OrgLetterhead,
|
||||||
Icon
|
Icon,
|
||||||
|
FieldChangesProvider,
|
||||||
|
FieldChangesBanner
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import { navigate } from '../../shared/routing';
|
import { navigate } from '../../shared/routing';
|
||||||
import {
|
import {
|
||||||
@@ -26,9 +25,14 @@ import {
|
|||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
APPROVAL_PHASE
|
APPROVAL_PHASE,
|
||||||
|
ApproverDecision
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
import { getAttachments } from '../../shared/attachments/attachments.service';
|
||||||
|
import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
|
||||||
|
import { EditableAttachments } from '../../shared/attachments/EditableAttachments';
|
||||||
|
import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
|
||||||
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
import {
|
import {
|
||||||
approvalListTitle,
|
approvalListTitle,
|
||||||
@@ -38,7 +42,10 @@ import {
|
|||||||
} from '../../shared/listTitles';
|
} from '../../shared/listTitles';
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField, organizationText } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField, organizationText } from './model';
|
||||||
import { loadWorkplaceRequest } from './load';
|
import { loadWorkplaceRequest } from './load';
|
||||||
import { workplaceApprovalService, updateReviewFields } from './submit';
|
import { workplaceApprovalService, updateReviewFields, updateAllWorkplaceFields } from './submit';
|
||||||
|
import { WORKPLACE_CHANGE_TRACKING } from './changeTracking';
|
||||||
|
import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation';
|
||||||
|
import { useApproverRights } from './useApproverRights';
|
||||||
import { EmployeeSection } from './sections/EmployeeSection';
|
import { EmployeeSection } from './sections/EmployeeSection';
|
||||||
import { EquipmentSection } from './sections/EquipmentSection';
|
import { EquipmentSection } from './sections/EquipmentSection';
|
||||||
import { useStableCallback } from '../../shared/useStableCallback';
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
@@ -56,9 +63,14 @@ interface IWorkplaceSetupViewProps {
|
|||||||
|
|
||||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||||
|
|
||||||
|
const REVIEW_FIELDS_HINT = 'Как согласующий вы можете заполнить «Необходимое ПО» и «Доступы».';
|
||||||
|
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
|
||||||
|
|
||||||
// Заявка «орг. рабочего места» только для просмотра (режим согласующего).
|
// Заявка «орг. рабочего места» только для просмотра (режим согласующего).
|
||||||
// Исключение: первый согласующий шаг (по маршруту — непосредственный руководитель)
|
// Исключения (оба — только на своём активном шаге, см. useApproverRights):
|
||||||
// на своём этапе может заполнить «Необходимое ПО» и «Доступы» (инициатор их не знает).
|
// - первый согласующий шаг (по маршруту — непосредственный руководитель) может
|
||||||
|
// заполнить «Необходимое ПО» и «Доступы» (инициатор их не знает);
|
||||||
|
// - согласующий из shared/approval/fullEditRights.ts правит все поля и вложения.
|
||||||
const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||||
@@ -66,13 +78,17 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
// при каждом рендере родителя.
|
// при каждом рендере родителя.
|
||||||
const notifyCode = useStableCallback(onCodeLoaded);
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
|
// То, что сейчас лежит в заявке на сервере (до несохранённых правок), — с ним
|
||||||
|
// сравниваем при сохранении, чтобы записать в журнал только реальные правки.
|
||||||
|
const [savedData, setSavedData] = useState<IFormData>(EMPTY_DATA);
|
||||||
|
// Журнал правок согласующего с полным правом — подсветка полей и «Было: …».
|
||||||
|
const [approverChanges, setApproverChanges] = useState<IFieldChange[]>([]);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
|
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [canEditFields, setCanEditFields] = useState<boolean>(false);
|
|
||||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||||
|
const [saveErrorMessage, setSaveErrorMessage] = useState<string>('');
|
||||||
// Какая из организаций заявителя (см. OrganizationField) идёт в печать —
|
// Какая из организаций заявителя (см. OrganizationField) идёт в печать —
|
||||||
// выбор в тулбаре через PrintSplitButton, по умолчанию первая.
|
// выбор в тулбаре через PrintSplitButton, по умолчанию первая.
|
||||||
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
|
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
|
||||||
@@ -85,10 +101,11 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
|
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
|
||||||
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
|
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
|
||||||
const dataListRef = listId || dataListTitle(FORM_ID);
|
const dataListRef = listId || dataListTitle(FORM_ID);
|
||||||
const approverService = useMemo(
|
const approval = useMemo(() => workplaceApprovalService(listId), [listId]);
|
||||||
() => new ApproverService(workplaceApprovalService(listId), dataListRef),
|
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
||||||
[listId, dataListRef]
|
const attachments = useAttachmentEditor(dataListRef, itemId);
|
||||||
);
|
// Сеттер стабилен (useState) — безопасно класть в зависимости эффекта загрузки.
|
||||||
|
const { setItems: setAttachmentItems } = attachments;
|
||||||
|
|
||||||
// Матрица маршрута из фактических строк заявки (read-only), разбитая по фазам.
|
// Матрица маршрута из фактических строк заявки (read-only), разбитая по фазам.
|
||||||
//
|
//
|
||||||
@@ -98,7 +115,6 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
// перечитывает матрицу. Иначе на экране остались бы данные, загруженные при
|
// перечитывает матрицу. Иначе на экране остались бы данные, загруженные при
|
||||||
// открытии формы, и пользователь видел бы свой шаг всё ещё «На согласовании».
|
// открытии формы, и пользователь видел бы свой шаг всё ещё «На согласовании».
|
||||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||||
const approval = useMemo(() => workplaceApprovalService(listId), [listId]);
|
|
||||||
const loadApprovers = useCallback(
|
const loadApprovers = useCallback(
|
||||||
() =>
|
() =>
|
||||||
approval
|
approval
|
||||||
@@ -120,17 +136,28 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
approval.getJobTitle(l)
|
approval.getJobTitle(l)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { canEditReviewFields, canEditAll } = useApproverRights(
|
||||||
|
context,
|
||||||
|
approval,
|
||||||
|
approverService,
|
||||||
|
itemId,
|
||||||
|
status,
|
||||||
|
decisionSeq
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.all([loadWorkplaceRequest(itemId, listId), getAttachments(listId || dataListTitle(FORM_ID), itemId)])
|
Promise.all([loadWorkplaceRequest(itemId, listId), getAttachments(dataListRef, itemId)])
|
||||||
.then(([loaded, files]) => {
|
.then(([loaded, files]) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
|
setSavedData(loaded.data);
|
||||||
|
setApproverChanges(loaded.approverChanges);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
notifyCode(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setAttachments(files);
|
setAttachmentItems(files);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -144,47 +171,45 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [itemId, listId, notifyCode]);
|
}, [itemId, listId, dataListRef, notifyCode, setAttachmentItems]);
|
||||||
|
|
||||||
// Правка «ПО»/«Доступы» доступна, когда сейчас очередь ПЕРВОГО шага согласования
|
const saveAllFields = async (): Promise<void> => {
|
||||||
// (по маршруту — непосредственного руководителя) и это текущий пользователь.
|
const editor = { name: context.pageContext.user.displayName, email: effectiveUserEmail(context) };
|
||||||
useEffect(() => {
|
const nextChanges = mergeFieldChanges(approverChanges, savedData, data, WORKPLACE_CHANGE_TRACKING, editor, new Date());
|
||||||
let alive = true;
|
await updateAllWorkplaceFields(itemId, data, nextChanges, listId);
|
||||||
Promise.all([
|
setApproverChanges(nextChanges);
|
||||||
approval.getForRequest(itemId),
|
setSavedData(data);
|
||||||
approverService.getPendingRowForCurrentUser(itemId)
|
await attachments.uploadPending();
|
||||||
])
|
};
|
||||||
.then(([rows, pending]) => {
|
|
||||||
if (!alive) return;
|
|
||||||
const orders = rows.filter((r) => r.phase !== APPROVAL_PHASE.execution).map((r) => r.order);
|
|
||||||
const firstOrder = orders.length > 0 ? Math.min(...orders) : undefined;
|
|
||||||
setCanEditFields(
|
|
||||||
!!pending && pending.phase !== APPROVAL_PHASE.execution && pending.order === firstOrder
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (alive) setCanEditFields(false);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
};
|
|
||||||
}, [itemId, approval, approverService]);
|
|
||||||
|
|
||||||
const saveReviewFields = useCallback(
|
const saveReviewFields = (): Promise<void> =>
|
||||||
() => updateReviewFields(itemId, { Software: data.Software, AccessRights: data.AccessRights }, listId),
|
updateReviewFields(itemId, { Software: data.Software, AccessRights: data.AccessRights }, listId);
|
||||||
[itemId, data.Software, data.AccessRights, listId]
|
|
||||||
);
|
// Полное право шире: оно включает и «ПО»/«Доступы».
|
||||||
|
const saveFields = (): Promise<void> => (canEditAll ? saveAllFields() : saveReviewFields());
|
||||||
|
|
||||||
const handleSave = async (): Promise<void> => {
|
const handleSave = async (): Promise<void> => {
|
||||||
setSaveState('saving');
|
setSaveState('saving');
|
||||||
|
setSaveErrorMessage('');
|
||||||
try {
|
try {
|
||||||
await saveReviewFields();
|
await saveFields();
|
||||||
setSaveState('saved');
|
setSaveState('saved');
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
setSaveErrorMessage(friendlyErrorMessage(e));
|
||||||
setSaveState('error');
|
setSaveState('error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Правки сохраняются вместе с решением — иначе решение ушло бы со старыми
|
||||||
|
// данными. Согласовать с очищенным обязательным полем нельзя; вернуть/
|
||||||
|
// отклонить — можно (бросок отменяет решение, текст показывает DecisionPanel).
|
||||||
|
const handleBeforeDecide = async (decision: ApproverDecision): Promise<void> => {
|
||||||
|
if (canEditAll && decision === 'approve' && !hasRequiredFields(data)) {
|
||||||
|
throw new Error(REQUIRED_FIELDS_MESSAGE);
|
||||||
|
}
|
||||||
|
await saveFields();
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||||
}
|
}
|
||||||
@@ -197,6 +222,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sectionProps = { data, set, context, printOrgIndex };
|
const sectionProps = { data, set, context, printOrgIndex };
|
||||||
|
const canEditAnything = canEditAll || canEditReviewFields;
|
||||||
|
|
||||||
const notify = (): void => {
|
const notify = (): void => {
|
||||||
if (!requestTypeId) return;
|
if (!requestTypeId) return;
|
||||||
@@ -222,16 +248,16 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
<PrintSplitButton value={organizationText(data)} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
<PrintSplitButton value={organizationText(data)} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canEditFields && (
|
{canEditAnything && (
|
||||||
<div className={`${styles.reviewBar} rp-no-print`}>
|
<div className={`${styles.reviewBar} rp-no-print`}>
|
||||||
<span className={styles.reviewHint}>
|
<span className={styles.reviewHint}>{canEditAll ? FULL_EDIT_HINT : REVIEW_FIELDS_HINT}</span>
|
||||||
Как согласующий вы можете заполнить «Необходимое ПО» и «Доступы».
|
|
||||||
</span>
|
|
||||||
<Button variant="secondary" onClick={handleSave} disabled={saveState === 'saving'}>
|
<Button variant="secondary" onClick={handleSave} disabled={saveState === 'saving'}>
|
||||||
Сохранить
|
Сохранить
|
||||||
</Button>
|
</Button>
|
||||||
{saveState === 'saved' && <span className={styles.savedNote}>Сохранено</span>}
|
{saveState === 'saved' && <span className={styles.savedNote}>Сохранено</span>}
|
||||||
{saveState === 'error' && <span className={styles.saveError}>Ошибка сохранения</span>}
|
{saveState === 'error' && (
|
||||||
|
<span className={styles.saveError}>{saveErrorMessage || 'Ошибка сохранения'}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -248,17 +274,16 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
}
|
}
|
||||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||||
>
|
>
|
||||||
<FormModeProvider readOnly>
|
<FieldChangesBanner changes={approverChanges} />
|
||||||
<EmployeeSection {...sectionProps} />
|
<FieldChangesProvider changes={toChangeMap(approverChanges)}>
|
||||||
<EquipmentSection {...sectionProps} approverEditable={canEditFields} />
|
<FormModeProvider readOnly={!canEditAll}>
|
||||||
</FormModeProvider>
|
<EmployeeSection {...sectionProps} />
|
||||||
|
<EquipmentSection {...sectionProps} approverEditable={canEditReviewFields} />
|
||||||
|
</FormModeProvider>
|
||||||
|
</FieldChangesProvider>
|
||||||
|
|
||||||
<div className="rp-no-print">
|
<div className="rp-no-print">
|
||||||
<CollapseSection title="Вложения" icon="upload">
|
<EditableAttachments editor={attachments} editable={canEditAll} />
|
||||||
<FormField label="Файлы" full>
|
|
||||||
<AttachmentList items={attachments} />
|
|
||||||
</FormField>
|
|
||||||
</CollapseSection>
|
|
||||||
</div>
|
</div>
|
||||||
</FormCard>
|
</FormCard>
|
||||||
|
|
||||||
@@ -286,7 +311,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
service={approverService}
|
service={approverService}
|
||||||
requestId={itemId}
|
requestId={itemId}
|
||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
onBeforeDecide={canEditFields ? saveReviewFields : undefined}
|
onBeforeDecide={canEditAnything ? handleBeforeDecide : undefined}
|
||||||
onDecided={(newStatus) => {
|
onDecided={(newStatus) => {
|
||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { IChangeTrackingConfig } from '../../shared/fieldChanges/fieldChanges';
|
||||||
|
import { IFormData } from './model';
|
||||||
|
|
||||||
|
/** Колонка журнала правок согласующего (Note, JSON) — см. provisioning.json. */
|
||||||
|
export const APPROVER_CHANGES_FIELD = 'WorkplaceApproverChangesJson';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Что пишем в журнал правок согласующего (shared/fieldChanges). Id отдела и
|
||||||
|
* проекта своего поля не имеют — меняются вместе с «Подразделение» и «Проект»;
|
||||||
|
* Organization — текстовая копия Companies (см. model.ts), правка видна по Companies.
|
||||||
|
*/
|
||||||
|
export const WORKPLACE_CHANGE_TRACKING: IChangeTrackingConfig<IFormData> = {
|
||||||
|
ignore: ['DepartmentId', 'ProjectId', 'Organization']
|
||||||
|
};
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
|
import { dataListTitle } from '../../shared/listTitles';
|
||||||
|
import { spDateOnlyToIso } from '../../shared/dateUtils';
|
||||||
|
import { REQUEST_STATUS, currentPartitionForPageUrl } from '../../shared/approval';
|
||||||
|
import { RequestTypeId } from '../../models/types';
|
||||||
|
import { queryAllPartitions, statusFilter } from '../RecruitingHire/partitionQuery';
|
||||||
|
import { IFormData } from './model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выбор «Заявки на прием», на основе которой создаётся «Организация рабочего
|
||||||
|
* места». Нужен тем, кто не видит чужую заявку на приём в портале и не может
|
||||||
|
* нажать «на основе» из неё самой.
|
||||||
|
*
|
||||||
|
* Из заявки на приём читаем ТОЛЬКО поля для предзаполнения (и для подписи в
|
||||||
|
* списке) — оклады и прочие данные кандидата в браузер не уходят.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Слаг формы приёма — ключ реестра форм и pageUrl типа заявки в approval-route. */
|
||||||
|
const HIRE_FORM_ID: RequestTypeId = 'recruiting-hire';
|
||||||
|
|
||||||
|
// Рабочее место готовят под уже утверждённый приём: «Согласовано», затем
|
||||||
|
// исполнение и закрытие — это тот же утверждённый приём (как isApprovedStatus).
|
||||||
|
const APPROVED_STATUSES: string[] = [REQUEST_STATUS.approved, REQUEST_STATUS.execution, REQUEST_STATUS.closed];
|
||||||
|
|
||||||
|
/** Поля заявки на приём, которые переносятся в форму рабочего места. */
|
||||||
|
const PREFILL_FIELDS = ['HireCandidateName', 'Phone', 'Position', 'Department', 'SiteLocation', 'HireHireDate'];
|
||||||
|
|
||||||
|
export interface IHireRequestOption {
|
||||||
|
id: number;
|
||||||
|
/** Серверный url партиции, где лежит заявка. '' — партиция неизвестна (текущий список по имени). */
|
||||||
|
listId: string;
|
||||||
|
/** Уникальный код заявки (напр. «5-2610-12»). '' — запись до появления поля кода. */
|
||||||
|
code: string;
|
||||||
|
candidateName: string;
|
||||||
|
position: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Поля формы рабочего места, которые заполняются из заявки на приём. */
|
||||||
|
export type HirePrefill = Pick<IFormData, 'FullName' | 'Phone' | 'Position' | 'Department' | 'Location' | 'StartDate'>;
|
||||||
|
|
||||||
|
function text(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Утверждённые заявки на приём из всех партиций серии, новые сверху. */
|
||||||
|
export async function getApprovedHireRequests(): Promise<IHireRequestOption[]> {
|
||||||
|
const rows = await queryAllPartitions(HIRE_FORM_ID, statusFilter(APPROVED_STATUSES), [
|
||||||
|
'Id',
|
||||||
|
'RequestUniqueCode',
|
||||||
|
'HireCandidateName',
|
||||||
|
'Position'
|
||||||
|
]);
|
||||||
|
return rows.map(({ listId, row }) => ({
|
||||||
|
id: Number(row.Id),
|
||||||
|
listId,
|
||||||
|
code: text(row.RequestUniqueCode),
|
||||||
|
candidateName: text(row.HireCandidateName),
|
||||||
|
position: text(row.Position)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Прочитать из заявки на приём только поля для предзаполнения. */
|
||||||
|
export async function loadHirePrefill(hireId: number, listId?: string): Promise<HirePrefill> {
|
||||||
|
const sp = getSP();
|
||||||
|
const list = listId ? sp.web.getList(listId) : sp.web.lists.getByTitle(dataListTitle(HIRE_FORM_ID));
|
||||||
|
const item = (await list.items.getById(hireId).select(...PREFILL_FIELDS)()) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
FullName: text(item.HireCandidateName),
|
||||||
|
Phone: text(item.Phone),
|
||||||
|
Position: text(item.Position),
|
||||||
|
Department: text(item.Department),
|
||||||
|
Location: text(item.SiteLocation),
|
||||||
|
StartDate: spDateOnlyToIso(item.HireHireDate)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Id для колонки-lookup SourceHire. Lookup смотрит только в ТЕКУЩУЮ партицию
|
||||||
|
* «Прием на работу — данные»: заявка из архивной партиции туда не попадёт, а
|
||||||
|
* тот же Id в текущем списке — это другая заявка. Поэтому для архивной связь не пишем.
|
||||||
|
*/
|
||||||
|
export function sourceHireLookupId(option: Pick<IHireRequestOption, 'id' | 'listId'>): number | undefined {
|
||||||
|
if (!option.listId) return option.id;
|
||||||
|
const current = currentPartitionForPageUrl(HIRE_FORM_ID);
|
||||||
|
return current && current.listId === option.listId ? option.id : undefined;
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ import { getSP } from '../../shared/pnp/pnpjs';
|
|||||||
import { dataListTitle } from '../../shared/listTitles';
|
import { dataListTitle } from '../../shared/listTitles';
|
||||||
import { spDateOnlyToIso } from '../../shared/dateUtils';
|
import { spDateOnlyToIso } from '../../shared/dateUtils';
|
||||||
import { ICompany } from '../../shared/dictionary';
|
import { ICompany } from '../../shared/dictionary';
|
||||||
|
import { IFieldChange, parseFieldChanges } from '../../shared/fieldChanges/fieldChanges';
|
||||||
import { COMPANIES_JSON_FIELD, FORM_ID, IFormData, EMPTY_DATA } from './model';
|
import { COMPANIES_JSON_FIELD, FORM_ID, IFormData, EMPTY_DATA } from './model';
|
||||||
|
import { APPROVER_CHANGES_FIELD } from './changeTracking';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON выбранных компаний → массив. Пусто — заявка до мультивыбора (тогда
|
* JSON выбранных компаний → массив. Пусто — заявка до мультивыбора (тогда
|
||||||
@@ -28,6 +30,8 @@ export interface ILoadedRequest {
|
|||||||
status: string;
|
status: string;
|
||||||
/** Уникальный код заявки; '' — если запись создана до появления поля. */
|
/** Уникальный код заявки; '' — если запись создана до появления поля. */
|
||||||
code: string;
|
code: string;
|
||||||
|
/** Журнал правок согласующего (подсветка полей) — см. shared/fieldChanges. */
|
||||||
|
approverChanges: IFieldChange[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,7 +46,14 @@ export async function loadWorkplaceRequest(itemId: number, listId?: string): Pro
|
|||||||
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||||
const item = (await list
|
const item = (await list
|
||||||
.items.getById(itemId)
|
.items.getById(itemId)
|
||||||
.select(...SCALAR_FIELDS, 'NeedsEquipment', COMPANIES_JSON_FIELD, 'RequestStatus', 'RequestUniqueCode')()
|
.select(
|
||||||
|
...SCALAR_FIELDS,
|
||||||
|
'NeedsEquipment',
|
||||||
|
COMPANIES_JSON_FIELD,
|
||||||
|
APPROVER_CHANGES_FIELD,
|
||||||
|
'RequestStatus',
|
||||||
|
'RequestUniqueCode'
|
||||||
|
)()
|
||||||
) as Record<string, unknown>;
|
) as Record<string, unknown>;
|
||||||
|
|
||||||
const data: IFormData = { ...EMPTY_DATA };
|
const data: IFormData = { ...EMPTY_DATA };
|
||||||
@@ -62,5 +73,10 @@ export async function loadWorkplaceRequest(itemId: number, listId?: string): Pro
|
|||||||
data.NeedsEquipment = item.NeedsEquipment === true;
|
data.NeedsEquipment = item.NeedsEquipment === true;
|
||||||
data.Companies = parseCompanies(item[COMPANIES_JSON_FIELD]);
|
data.Companies = parseCompanies(item[COMPANIES_JSON_FIELD]);
|
||||||
|
|
||||||
return { data, status: (item.RequestStatus as string) || '', code: (item.RequestUniqueCode as string) || '' };
|
return {
|
||||||
|
data,
|
||||||
|
status: (item.RequestStatus as string) || '',
|
||||||
|
code: (item.RequestUniqueCode as string) || '',
|
||||||
|
approverChanges: parseFieldChanges(item[APPROVER_CHANGES_FIELD])
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import { loadHireRequest } from '../RecruitingHire/load';
|
|
||||||
import { IFormData, EMPTY_DATA } from './model';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Предзаполнить форму «орг. рабочего места» из «Заявки на прием» (снимок на момент создания).
|
|
||||||
* Копируются согласованные поля; остальное остаётся пустым для ручного заполнения.
|
|
||||||
*/
|
|
||||||
export async function loadPrefillFromHire(hireId: number): Promise<IFormData> {
|
|
||||||
const { data: hire } = await loadHireRequest(hireId);
|
|
||||||
return {
|
|
||||||
...EMPTY_DATA,
|
|
||||||
FullName: hire.HireCandidateName,
|
|
||||||
Phone: hire.Phone,
|
|
||||||
Position: hire.Position,
|
|
||||||
Department: hire.Department,
|
|
||||||
Location: hire.SiteLocation,
|
|
||||||
StartDate: hire.HireHireDate
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
{ "internalName": "NeedsEquipment", "title": "Нужна ли техника", "type": "Boolean" },
|
{ "internalName": "NeedsEquipment", "title": "Нужна ли техника", "type": "Boolean" },
|
||||||
{ "internalName": "Software", "title": "Необходимое ПО", "type": "Note" },
|
{ "internalName": "Software", "title": "Необходимое ПО", "type": "Note" },
|
||||||
{ "internalName": "AccessRights", "title": "Доступы", "type": "Note" },
|
{ "internalName": "AccessRights", "title": "Доступы", "type": "Note" },
|
||||||
|
{ "internalName": "WorkplaceApproverChangesJson", "title": "Изменения согласующего — рабочее место (JSON)", "type": "Note" },
|
||||||
|
|
||||||
{ "internalName": "SourceHire", "title": "Источник (заявка на прием)", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" }
|
{ "internalName": "SourceHire", "title": "Источник (заявка на прием)", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const EquipmentSection: React.FC<IEquipmentSectionProps> = ({ data, set,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CollapseSection title="Оборудование и доступы" icon="monitor">
|
<CollapseSection title="Оборудование и доступы" icon="monitor">
|
||||||
<FormField label="Нужна ли техника">
|
<FormField label="Нужна ли техника" fieldKey="NeedsEquipment">
|
||||||
<Toggle checked={data.NeedsEquipment} onChange={(v) => set('NeedsEquipment', v)} onLabel="Да" />
|
<Toggle checked={data.NeedsEquipment} onChange={(v) => set('NeedsEquipment', v)} onLabel="Да" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Необходимое ПО" htmlFor="Software" full>
|
<FormField label="Необходимое ПО" htmlFor="Software" full>
|
||||||
|
|||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 13px;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $white;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: $gray-900;
|
||||||
|
transition: .18s;
|
||||||
|
|
||||||
|
@include input-focus-gold;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-top: $sp-1;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
box-shadow: $sh-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-900;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $navy-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
font-size: $fs-caption;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2 $sp-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedLabel {
|
||||||
|
min-width: 0;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-700;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textBtn {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-700;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $navy-700;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: $sp-2 0 0;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './HireRequestPicker.module.scss';
|
||||||
|
import { CollapseSection, FormField } from '../../../shared/ui';
|
||||||
|
import { getApprovedHireRequests, IHireRequestOption } from '../hireLookup';
|
||||||
|
|
||||||
|
const MAX_SUGGESTIONS = 30;
|
||||||
|
const HINT =
|
||||||
|
'ФИО, телефон, должность, подразделение, местоположение и дата приёма заполнятся из выбранной заявки — их можно поправить.';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Выбранная заявка на приём; undefined — не выбрана. */
|
||||||
|
selected: IHireRequestOption | undefined;
|
||||||
|
onPick: (option: IHireRequestOption) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Читаемая подпись: «№ код — ФИО кандидата». */
|
||||||
|
function optionLabel(o: IHireRequestOption): string {
|
||||||
|
const head = `№ ${o.code || o.id}`;
|
||||||
|
return o.candidateName ? `${head} — ${o.candidateName}` : head;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(o: IHireRequestOption, query: string): boolean {
|
||||||
|
return [o.code, o.candidateName, o.position].some((v) => v.toLowerCase().indexOf(query) !== -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «На основе заявки на приём»: поиск утверждённой заявки на приём по номеру,
|
||||||
|
* ФИО кандидата или должности. Выбор предзаполняет форму (делает родитель в onPick).
|
||||||
|
* Показывается только в редактируемой форме.
|
||||||
|
*/
|
||||||
|
export const HireRequestPicker: React.FC<Props> = ({ selected, onPick, onClear }) => {
|
||||||
|
const [options, setOptions] = React.useState<IHireRequestOption[]>([]);
|
||||||
|
const [loadState, setLoadState] = React.useState<'idle' | 'loading' | 'loaded' | 'error'>('idle');
|
||||||
|
const [query, setQuery] = React.useState<string>('');
|
||||||
|
const [open, setOpen] = React.useState<boolean>(false);
|
||||||
|
|
||||||
|
// Список грузим лениво — только когда пользователь начинает выбирать.
|
||||||
|
const ensureLoaded = (): void => {
|
||||||
|
if (loadState !== 'idle') return;
|
||||||
|
setLoadState('loading');
|
||||||
|
getApprovedHireRequests()
|
||||||
|
.then((loaded) => {
|
||||||
|
setOptions(loaded);
|
||||||
|
setLoadState('loaded');
|
||||||
|
})
|
||||||
|
.catch(() => setLoadState('error'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
const suggestions = (q ? options.filter((o) => matches(o, q)) : options).slice(0, MAX_SUGGESTIONS);
|
||||||
|
|
||||||
|
const handlePick = (o: IHireRequestOption): void => {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery('');
|
||||||
|
onPick(o);
|
||||||
|
};
|
||||||
|
|
||||||
|
let emptyText = 'Загрузка…';
|
||||||
|
if (loadState === 'error') emptyText = 'Не удалось загрузить заявки на приём.';
|
||||||
|
if (loadState === 'loaded') emptyText = 'Утверждённых заявок на приём не найдено.';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapseSection title="На основе заявки на приём" icon="copy">
|
||||||
|
<FormField label="Заявка на приём" htmlFor="SourceHire" full>
|
||||||
|
{selected ? (
|
||||||
|
<div className={styles.selected}>
|
||||||
|
<span className={styles.selectedLabel}>{optionLabel(selected)}</span>
|
||||||
|
<button type="button" className={styles.textBtn} onClick={onClear}>
|
||||||
|
Очистить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<input
|
||||||
|
id="SourceHire"
|
||||||
|
className={styles.input}
|
||||||
|
value={query}
|
||||||
|
placeholder="Начните вводить номер, ФИО кандидата или должность…"
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
ensureLoaded();
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
ensureLoaded();
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onBlur={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
{open && (
|
||||||
|
<div className={styles.list}>
|
||||||
|
{suggestions.length > 0 ? (
|
||||||
|
suggestions.map((o) => (
|
||||||
|
<button
|
||||||
|
key={`${o.listId}:${o.id}`}
|
||||||
|
type="button"
|
||||||
|
className={styles.item}
|
||||||
|
// preventDefault на mousedown — иначе input теряет фокус (onBlur)
|
||||||
|
// раньше onClick, и список закрывается до выбора.
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => handlePick(o)}
|
||||||
|
>
|
||||||
|
{optionLabel(o)}
|
||||||
|
{o.position && <span className={styles.meta}>{o.position}</span>}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className={styles.note}>{emptyText}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className={styles.hint}>{HINT}</p>
|
||||||
|
</FormField>
|
||||||
|
</CollapseSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||||
import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList';
|
import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList';
|
||||||
|
import { IFieldChange, serializeFieldChanges } from '../../shared/fieldChanges/fieldChanges';
|
||||||
import { COMPANIES_JSON_FIELD, FORM_ID, IFormData, organizationText } from './model';
|
import { COMPANIES_JSON_FIELD, FORM_ID, IFormData, organizationText } from './model';
|
||||||
|
import { APPROVER_CHANGES_FIELD } from './changeTracking';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
|
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
|
||||||
@@ -30,14 +32,31 @@ export async function updateReviewFields(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Записать все поля заявки, которые поправил согласующий с полным правом
|
||||||
|
* (см. shared/approval/fullEditRights.ts), — вместе с журналом его правок
|
||||||
|
* (подсветка полей, см. shared/fieldChanges). Статус, маршрут и вложения не
|
||||||
|
* трогаются: заявка остаётся на том же шаге согласования.
|
||||||
|
*/
|
||||||
|
export async function updateAllWorkplaceFields(
|
||||||
|
id: number,
|
||||||
|
data: IFormData,
|
||||||
|
approverChanges: IFieldChange[],
|
||||||
|
listId?: string
|
||||||
|
): Promise<void> {
|
||||||
|
const item = buildContentFields(data);
|
||||||
|
item[APPROVER_CHANGES_FIELD] = serializeFieldChanges(approverChanges);
|
||||||
|
await resolveDataList(FORM_ID, listId).items.getById(id).update(item);
|
||||||
|
}
|
||||||
|
|
||||||
/** Осмысленный заголовок элемента (системная колонка Title). */
|
/** Осмысленный заголовок элемента (системная колонка Title). */
|
||||||
function buildTitle(data: IFormData): string {
|
function buildTitle(data: IFormData): string {
|
||||||
return ['Раб. место', data.FullName, data.Position].filter(Boolean).join(' — ');
|
return ['Раб. место', data.FullName, data.Position].filter(Boolean).join(' — ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Поля элемента списка данных из модели формы. */
|
/** Содержательные поля элемента списка данных из модели формы (без статуса и источника). */
|
||||||
function buildItem(data: IFormData, status: string, sourceHireId?: number): Record<string, unknown> {
|
function buildContentFields(data: IFormData): Record<string, unknown> {
|
||||||
const item: Record<string, unknown> = { Title: buildTitle(data), RequestStatus: status };
|
const item: Record<string, unknown> = { Title: buildTitle(data) };
|
||||||
|
|
||||||
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
|
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
|
||||||
if (key === 'Companies') {
|
if (key === 'Companies') {
|
||||||
@@ -58,6 +77,14 @@ function buildItem(data: IFormData, status: string, sourceHireId?: number): Reco
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Поля элемента списка данных при сохранении заявки целиком. */
|
||||||
|
function buildItem(data: IFormData, status: string, sourceHireId?: number): Record<string, unknown> {
|
||||||
|
const item = buildContentFields(data);
|
||||||
|
item.RequestStatus = status;
|
||||||
|
|
||||||
if (sourceHireId) {
|
if (sourceHireId) {
|
||||||
item.SourceHireId = sourceHireId; // lookup → «Прием на работу — данные»
|
item.SourceHireId = sourceHireId; // lookup → «Прием на работу — данные»
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import {
|
||||||
|
ApprovalListService,
|
||||||
|
ApproverService,
|
||||||
|
APPROVAL_PHASE,
|
||||||
|
REQUEST_STATUS,
|
||||||
|
canEditAllFields
|
||||||
|
} from '../../shared/approval';
|
||||||
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
|
import { FORM_ID } from './model';
|
||||||
|
|
||||||
|
export interface IApproverRights {
|
||||||
|
/** Первый шаг согласования (руководитель) может заполнить «Необходимое ПО» и «Доступы». */
|
||||||
|
canEditReviewFields: boolean;
|
||||||
|
/** Участник маршрута из shared/approval/fullEditRights.ts правит все поля и вложения. */
|
||||||
|
canEditAll: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_RIGHTS: IApproverRights = { canEditReviewFields: false, canEditAll: false };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Статусы заявки, в которых работает полное право. Закрытую/отклонённую
|
||||||
|
* задним числом не правим, а на доработке заявку правит автор — правки
|
||||||
|
* перезаписывали бы друг друга.
|
||||||
|
*/
|
||||||
|
const FULL_EDIT_STATUSES: ReadonlyArray<string> = [REQUEST_STATUS.onApproval, REQUEST_STATUS.execution];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Что текущий пользователь может править в поданной заявке.
|
||||||
|
* - «ПО»/«Доступы» — только на СВОЁМ активном шаге согласования, если это первый шаг.
|
||||||
|
* - Все поля — если пользователь есть в маршруте (согласование или исполнение)
|
||||||
|
* НЕЗАВИСИМО от того, чья сейчас очередь, пока заявка в работе (решение
|
||||||
|
* Тимура 2026-10-07: так в «Организации рабочего места» работает Ербол).
|
||||||
|
* reloadKey — после решения/делегирования маршрут меняется, права пересчитываются.
|
||||||
|
*/
|
||||||
|
export function useApproverRights(
|
||||||
|
context: WebPartContext,
|
||||||
|
approval: ApprovalListService,
|
||||||
|
approverService: ApproverService,
|
||||||
|
itemId: number,
|
||||||
|
requestStatus: string,
|
||||||
|
reloadKey: number
|
||||||
|
): IApproverRights {
|
||||||
|
const [rights, setRights] = useState<IApproverRights>(NO_RIGHTS);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
Promise.all([
|
||||||
|
approval.getForRequest(itemId),
|
||||||
|
approverService.getPendingRowForCurrentUser(itemId),
|
||||||
|
approval.getCurrentUserId()
|
||||||
|
])
|
||||||
|
.then(([rows, pending, userId]) => {
|
||||||
|
if (!alive) return;
|
||||||
|
const approvalOrders = rows.filter((r) => r.phase !== APPROVAL_PHASE.execution).map((r) => r.order);
|
||||||
|
const firstOrder = approvalOrders.length > 0 ? Math.min(...approvalOrders) : undefined;
|
||||||
|
const isFirstApprovalStep =
|
||||||
|
!!pending && pending.phase !== APPROVAL_PHASE.execution && pending.order === firstOrder;
|
||||||
|
const isInRoute = rows.some((r) => r.approverId === userId);
|
||||||
|
|
||||||
|
setRights({
|
||||||
|
canEditReviewFields: isFirstApprovalStep,
|
||||||
|
canEditAll:
|
||||||
|
isInRoute &&
|
||||||
|
FULL_EDIT_STATUSES.indexOf(requestStatus) !== -1 &&
|
||||||
|
canEditAllFields(FORM_ID, effectiveUserEmail(context))
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (alive) setRights(NO_RIGHTS);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [context, approval, approverService, itemId, requestStatus, reloadKey]);
|
||||||
|
|
||||||
|
return rights;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { IFormData } from './model';
|
||||||
|
|
||||||
|
/** Заполнены ли обязательные поля заявки. */
|
||||||
|
export function hasRequiredFields(data: IFormData): boolean {
|
||||||
|
return (
|
||||||
|
data.FullName.trim().length > 0 &&
|
||||||
|
data.NameLatin.trim().length > 0 &&
|
||||||
|
data.Position.trim().length > 0 &&
|
||||||
|
data.Companies.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REQUIRED_FIELDS_MESSAGE =
|
||||||
|
'Заполните обязательные поля: Ф.И.О., Фамилия и имя (латиница), Должность, Организация.';
|
||||||
@@ -1,21 +1,25 @@
|
|||||||
|
import { RequestTypeId } from '../../models/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Кто на СВОЁМ АКТИВНОМ шаге согласования может править ВСЕ поля заявки
|
* Кто на СВОЁМ АКТИВНОМ шаге согласования может править ВСЕ поля заявки
|
||||||
* (а не только отдельные, как расчётная группа в RecruitingHire).
|
* (а не только отдельные, как расчётная группа в RecruitingHire).
|
||||||
*
|
*
|
||||||
* Пока фиксированный email одного человека (Алтынай Бултурукова, начальник
|
* Пока фиксированные email по формам — групп SharePoint для этого ещё нет,
|
||||||
* отдела подбора и адаптации персонала) — группы SharePoint для этого ещё
|
* как и в routeEditRights.ts. ВРЕМЕННО. Список свой у каждой формы: право в
|
||||||
* нет, как и в routeEditRights.ts. ВРЕМЕННО.
|
* одной форме не должно молча открывать правку в другой.
|
||||||
*
|
*
|
||||||
* Подключено только в «Заявке на подбор» и «Заявке на прием» (Реферальная
|
* Реферальная рекомендация — без права (решение Тимура 2026-09-24). С
|
||||||
* рекомендация — нет, по решению Тимура 2026-09-24). С 2026-10-06 право
|
* 2026-10-06 право открывает и вложения: добавлять и удалять файлы (см.
|
||||||
* открывает и вложения: добавлять и удалять файлы (см. shared/attachments/
|
* shared/attachments/useAttachmentEditor).
|
||||||
* useAttachmentEditor).
|
|
||||||
*/
|
*/
|
||||||
const FULL_FIELD_EDITOR_EMAILS: ReadonlySet<string> = new Set([
|
const FULL_FIELD_EDITORS: Partial<Record<RequestTypeId, ReadonlyArray<string>>> = {
|
||||||
'a.bulturukova@tnsu.kz' // Алтынай Бултурукова
|
'recruiting-selection': ['a.bulturukova@tnsu.kz'], // Алтынай Бултурукова
|
||||||
]);
|
'recruiting-hire': ['a.bulturukova@tnsu.kz'], // Алтынай Бултурукова
|
||||||
|
'it-workplace-setup': ['shaimerdenov.yerbol@tnsu.kz'] // Ербол Шаймерденов
|
||||||
|
};
|
||||||
|
|
||||||
/** Может ли этот email править все поля заявки на своём активном шаге (см. выше). */
|
/** Может ли этот email править все поля заявки этой формы на своём активном шаге (см. выше). */
|
||||||
export function canEditAllFields(email: string | undefined): boolean {
|
export function canEditAllFields(formId: RequestTypeId, email: string | undefined): boolean {
|
||||||
return !!email && FULL_FIELD_EDITOR_EMAILS.has(email.trim().toLowerCase());
|
const editors = FULL_FIELD_EDITORS[formId];
|
||||||
|
return !!email && !!editors && editors.indexOf(email.trim().toLowerCase()) !== -1;
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-8
@@ -1,9 +1,9 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Alert, AttachmentList, CollapseSection, FileDropzone, FormField } from '../../shared/ui';
|
import { Alert, AttachmentList, CollapseSection, FileDropzone, FormField } from '../ui';
|
||||||
import { IAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
|
import { IAttachmentEditor } from './useAttachmentEditor';
|
||||||
|
|
||||||
/** Единственная группа новых файлов в этой форме (категорий нет). */
|
/** Единственная группа новых файлов (категорий нет — в отличие от заявки на приём). */
|
||||||
export const SELECTION_FILES_GROUP = 'files';
|
export const ATTACHMENTS_FILES_GROUP = 'files';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
editor: IAttachmentEditor;
|
editor: IAttachmentEditor;
|
||||||
@@ -11,9 +11,12 @@ interface Props {
|
|||||||
editable: boolean;
|
editable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Блок «Вложения» в просмотре заявки на подбор. */
|
/**
|
||||||
export const SelectionAttachments: React.FC<Props> = ({ editor, editable }) => {
|
* Блок «Вложения» в просмотре заявки без категорий файлов: список, а при
|
||||||
const newFiles = editor.pending[SELECTION_FILES_GROUP] || [];
|
* полном праве правки (shared/approval/fullEditRights.ts) — удаление и загрузка.
|
||||||
|
*/
|
||||||
|
export const EditableAttachments: React.FC<Props> = ({ editor, editable }) => {
|
||||||
|
const newFiles = editor.pending[ATTACHMENTS_FILES_GROUP] || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollapseSection title="Вложения" icon="upload">
|
<CollapseSection title="Вложения" icon="upload">
|
||||||
@@ -23,7 +26,7 @@ export const SelectionAttachments: React.FC<Props> = ({ editor, editable }) => {
|
|||||||
</FormField>
|
</FormField>
|
||||||
{editable && (
|
{editable && (
|
||||||
<FormField label="Добавить файлы" full>
|
<FormField label="Добавить файлы" full>
|
||||||
<FileDropzone files={newFiles} onChange={(files) => editor.setPending(SELECTION_FILES_GROUP, files)} />
|
<FileDropzone files={newFiles} onChange={(files) => editor.setPending(ATTACHMENTS_FILES_GROUP, files)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
</CollapseSection>
|
</CollapseSection>
|
||||||
Reference in New Issue
Block a user