fix: внес изменения в заявках рекрутинга от 22.09.2026
Tests / frontend-test (push) Successful in 1m19s
Tests / frontend-test (push) Successful in 1m19s
This commit is contained in:
@@ -27,21 +27,21 @@ export const REQUEST_TYPES: IRequestType[] = [
|
|||||||
comingSoon: true
|
comingSoon: true
|
||||||
},
|
},
|
||||||
|
|
||||||
// Склад / Снабжение
|
// Склад / Снабжение — формы открыты для ознакомления, но приём заявок ещё не
|
||||||
{ id: 'wh-tmc-transfer', categoryId: 'warehouse', title: 'СЗ на перемещение ТМЦ', icon: 'send', comingSoon: true },
|
// запущен (маршруты/списки не готовы): кнопки сохранения/отправки в самих
|
||||||
|
// формах задизейблены (см. SubmitActions в каждой из них).
|
||||||
|
{ id: 'wh-tmc-transfer', categoryId: 'warehouse', title: 'СЗ на перемещение ТМЦ', icon: 'send' },
|
||||||
{
|
{
|
||||||
id: 'wh-service-request',
|
id: 'wh-service-request',
|
||||||
categoryId: 'warehouse',
|
categoryId: 'warehouse',
|
||||||
title: 'Заявка на услуги',
|
title: 'Заявка на услуги',
|
||||||
icon: 'document',
|
icon: 'document'
|
||||||
comingSoon: true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'wh-concrete-request',
|
id: 'wh-concrete-request',
|
||||||
categoryId: 'warehouse',
|
categoryId: 'warehouse',
|
||||||
title: 'Заявка на бетон',
|
title: 'Заявка на бетон',
|
||||||
icon: 'vehicle',
|
icon: 'vehicle'
|
||||||
comingSoon: true
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ import {
|
|||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
IApproverToWrite,
|
IApproverToWrite,
|
||||||
APPROVAL_PHASE,
|
APPROVAL_PHASE,
|
||||||
unassignedStepMessage
|
unassignedStepMessage,
|
||||||
|
resolveApproverOrgInfo,
|
||||||
|
canEditRoute
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import {
|
import {
|
||||||
uploadAttachments,
|
uploadAttachments,
|
||||||
@@ -45,6 +47,7 @@ import {
|
|||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} 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 { missingRequiredFields } from './validation';
|
||||||
import { saveHireRequest, hireApprovalService } from './submit';
|
import { saveHireRequest, hireApprovalService } from './submit';
|
||||||
import { loadHireRequest } from './load';
|
import { loadHireRequest } from './load';
|
||||||
import {
|
import {
|
||||||
@@ -133,6 +136,9 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
// hireApprovalService можно звать не опасаясь исключения.
|
// hireApprovalService можно звать не опасаясь исключения.
|
||||||
const approval = useMemo(() => hireApprovalService(editListId), [editListId]);
|
const approval = useMemo(() => hireApprovalService(editListId), [editListId]);
|
||||||
const userEmail = effectiveUserEmail(context);
|
const userEmail = effectiveUserEmail(context);
|
||||||
|
// Право удалять согласующих из маршрута ещё на этапе составления заявки —
|
||||||
|
// см. shared/approval/routeEditRights.ts (сейчас — Алтынай Бултурукова).
|
||||||
|
const canRemoveApprover = canEditRoute(userEmail);
|
||||||
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
|
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
|
||||||
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
|
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
|
||||||
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
||||||
@@ -173,7 +179,8 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
`${userEmail}-${editId ?? 'new'}-${projectId}-${companiesFieldValue}`,
|
`${userEmail}-${editId ?? 'new'}-${projectId}-${companiesFieldValue}`,
|
||||||
!readOnly,
|
!readOnly,
|
||||||
matrixReady,
|
matrixReady,
|
||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login),
|
||||||
|
resolveApproverOrgInfo(context)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Фаза исполнения: исполнители из маршрута (status='Execution'), только для чтения.
|
// Фаза исполнения: исполнители из маршрута (status='Execution'), только для чтения.
|
||||||
@@ -191,7 +198,8 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
`${userEmail}-${editId ?? 'new'}-${projectId}-${companiesFieldValue}`,
|
`${userEmail}-${editId ?? 'new'}-${projectId}-${companiesFieldValue}`,
|
||||||
false,
|
false,
|
||||||
matrixReady,
|
matrixReady,
|
||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login),
|
||||||
|
resolveApproverOrgInfo(context)
|
||||||
);
|
);
|
||||||
|
|
||||||
// При совмещении (2+ компании в «Организация») нужно явно указать, какая из них основная.
|
// При совмещении (2+ компании в «Организация») нужно явно указать, какая из них основная.
|
||||||
@@ -215,6 +223,11 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Остальные поля формы обязательны только для ОТПРАВКИ на согласование —
|
||||||
|
// черновик по-прежнему можно сохранить не заполнив их (см. validation.ts).
|
||||||
|
const missingFields = missingRequiredFields(data);
|
||||||
|
const isSubmitValid = isValid && missingFields.length === 0;
|
||||||
|
|
||||||
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
||||||
// order обязателен: параллельные шаги делят один номер, по позиции в
|
// order обязателен: параллельные шаги делят один номер, по позиции в
|
||||||
// массиве его не восстановить (см. useMatrixEditor/withOrders).
|
// массиве его не восстановить (см. useMatrixEditor/withOrders).
|
||||||
@@ -306,6 +319,9 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
const validateSubmit = (): string | undefined => {
|
const validateSubmit = (): string | undefined => {
|
||||||
const formError = validate();
|
const formError = validate();
|
||||||
if (formError) return formError;
|
if (formError) return formError;
|
||||||
|
if (missingFields.length > 0) {
|
||||||
|
return `Заполните обязательные поля: ${missingFields.join(', ')}.`;
|
||||||
|
}
|
||||||
if (matrix.error) {
|
if (matrix.error) {
|
||||||
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
||||||
}
|
}
|
||||||
@@ -419,6 +435,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
waiting={!matrixReady}
|
waiting={!matrixReady}
|
||||||
waitingText="Выберите «Проект», чтобы построить маршрут согласования."
|
waitingText="Выберите «Проект», чтобы построить маршрут согласования."
|
||||||
|
canRemoveRow={canRemoveApprover ? () => true : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!executors.loading && executors.rows.length > 0 && (
|
{!executors.loading && executors.rows.length > 0 && (
|
||||||
@@ -434,6 +451,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
<SubmitActions
|
<SubmitActions
|
||||||
submit={submit}
|
submit={submit}
|
||||||
isValid={isValid}
|
isValid={isValid}
|
||||||
|
submitValid={isSubmitValid}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
onSaveDraft={handleSaveDraft}
|
onSaveDraft={handleSaveDraft}
|
||||||
|
|||||||
@@ -18,3 +18,35 @@
|
|||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
color: $gray-500;
|
color: $gray-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Полоса «вы можете заполнить оклады» для начальника расчётной группы —
|
||||||
|
// см. approverRights.ts (тот же приём, что и в Clearance/ClearanceView).
|
||||||
|
.reviewBar {
|
||||||
|
display: flex;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,9 +12,14 @@ import {
|
|||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
APPROVAL_PHASE,
|
APPROVAL_PHASE,
|
||||||
isApprovedStatus
|
isApprovedStatus,
|
||||||
|
resolveApproverOrgInfo,
|
||||||
|
canEditRoute,
|
||||||
|
REQUEST_STATUS,
|
||||||
|
IMatrixRow
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { navigate } from '../../shared/routing';
|
import { navigate } from '../../shared/routing';
|
||||||
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
import {
|
import {
|
||||||
@@ -27,7 +32,8 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
|||||||
import { loadHireRequest } from './load';
|
import { loadHireRequest } from './load';
|
||||||
import { ATT_CATEGORY_ID, ATT_CATEGORY_RESUME, attachmentsOfCategory, uncategorizedAttachments } from './attachmentCategories';
|
import { ATT_CATEGORY_ID, ATT_CATEGORY_RESUME, attachmentsOfCategory, uncategorizedAttachments } from './attachmentCategories';
|
||||||
import { confirmDiscardFormChanges } from '../../shared/unsavedChanges';
|
import { confirmDiscardFormChanges } from '../../shared/unsavedChanges';
|
||||||
import { hireApprovalService } from './submit';
|
import { hireApprovalService, updateApproverFields, IApproverEditableFields } from './submit';
|
||||||
|
import { canEditPayrollFields } from './approverRights';
|
||||||
import { ApplicationSection } from './sections/ApplicationSection';
|
import { ApplicationSection } from './sections/ApplicationSection';
|
||||||
import { CandidateSection } from './sections/CandidateSection';
|
import { CandidateSection } from './sections/CandidateSection';
|
||||||
import { PpeSection } from './sections/PpeSection';
|
import { PpeSection } from './sections/PpeSection';
|
||||||
@@ -50,12 +56,12 @@ interface IRecruitingHireViewProps {
|
|||||||
// производной заявки «на основе прием».
|
// производной заявки «на основе прием».
|
||||||
const WORKPLACE_FORM_KEY = 'it-workplace-setup';
|
const WORKPLACE_FORM_KEY = 'it-workplace-setup';
|
||||||
|
|
||||||
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||||
const NOOP_SET: SetField = () => undefined;
|
|
||||||
|
|
||||||
// Заявка «на прием» только для просмотра (режим согласующего).
|
// Заявка «на прием» только для просмотра (режим согласующего). Переиспользует
|
||||||
// Переиспользует те же секции, что и форма подачи; редактирование выключено
|
// те же секции, что и форма подачи; редактирование выключено через
|
||||||
// через <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
// <FormModeProvider readOnly>, КРОМЕ окладов (4.2/4.3) и отдела/участка (2.5) —
|
||||||
|
// их на своём шаге правит начальник расчётной группы (см. approverRights.ts).
|
||||||
const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||||
@@ -68,6 +74,17 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
|
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 [payrollEditable, setPayrollEditable] = useState<boolean>(false);
|
||||||
|
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||||
|
const [saveErrorMessage, setSaveErrorMessage] = useState<string>('');
|
||||||
|
// Право удалять согласующих из уже поданного маршрута — см.
|
||||||
|
// shared/approval/routeEditRights.ts (сейчас — Алтынай Бултурукова). Не
|
||||||
|
// привязано к активному шагу: в отличие от payrollEditable, это право не
|
||||||
|
// зависит от того, чья сейчас очередь согласовывать.
|
||||||
|
const canRemoveApprover = canEditRoute(effectiveUserEmail(context));
|
||||||
|
const [routeError, setRouteError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
const { id: requestTypeId } = useRequestTypeId(context, formKey);
|
const { id: requestTypeId } = useRequestTypeId(context, formKey);
|
||||||
|
|
||||||
@@ -100,7 +117,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
[approval, itemId]
|
[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), resolveApproverOrgInfo(context)
|
||||||
);
|
);
|
||||||
const loadExecutors = useCallback(
|
const loadExecutors = useCallback(
|
||||||
() =>
|
() =>
|
||||||
@@ -110,9 +127,66 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
[approval, itemId]
|
[approval, itemId]
|
||||||
);
|
);
|
||||||
const executors = useMatrixEditor(loadExecutors, `e-${itemId}-${decisionSeq}`, false, true, (l) =>
|
const executors = useMatrixEditor(loadExecutors, `e-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||||
approval.getJobTitle(l)
|
approval.getJobTitle(l), resolveApproverOrgInfo(context)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Права на оклады и отдел/участок — по должности ТЕКУЩЕГО активного шага
|
||||||
|
// пользователя. isDelegator намеренно не даёт прав: делегировавший не решает
|
||||||
|
// за делегата (см. DecisionPanel), значит и эти поля за него править не должен.
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
approverService
|
||||||
|
.getPendingRowForCurrentUser(itemId)
|
||||||
|
.then((pending) => {
|
||||||
|
if (!alive) return;
|
||||||
|
const active = !!pending && !pending.isDelegator && pending.phase !== APPROVAL_PHASE.execution;
|
||||||
|
setPayrollEditable(active && canEditPayrollFields(pending?.position));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (alive) setPayrollEditable(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
// decisionSeq: после решения/делегирования активный шаг меняется — права пересчитываем.
|
||||||
|
}, [itemId, approverService, decisionSeq]);
|
||||||
|
|
||||||
|
// Пишем ТОЛЬКО эти поля: иначе согласующий затёр бы остальную заявку
|
||||||
|
// устаревшим снимком формы, загруженным при открытии экрана.
|
||||||
|
const saveApproverFields = useCallback(async (): Promise<void> => {
|
||||||
|
if (!payrollEditable) return;
|
||||||
|
const fields: Partial<IApproverEditableFields> = {
|
||||||
|
SalaryProbation: data.SalaryProbation,
|
||||||
|
SalaryAfterProbation: data.SalaryAfterProbation,
|
||||||
|
Department: data.Department
|
||||||
|
};
|
||||||
|
await updateApproverFields(itemId, fields, listId);
|
||||||
|
}, [itemId, listId, payrollEditable, data.SalaryProbation, data.SalaryAfterProbation, data.Department]);
|
||||||
|
|
||||||
|
const handleSaveFields = async (): Promise<void> => {
|
||||||
|
setSaveState('saving');
|
||||||
|
setSaveErrorMessage('');
|
||||||
|
try {
|
||||||
|
await saveApproverFields();
|
||||||
|
setSaveState('saved');
|
||||||
|
} catch (e) {
|
||||||
|
setSaveErrorMessage(friendlyErrorMessage(e));
|
||||||
|
setSaveState('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Удалить ещё не решённый шаг маршрута (см. ApproverService.deleteQueuedStep) —
|
||||||
|
// строка без id (не должно случиться для canRemoveRow, но на всякий случай)
|
||||||
|
// просто игнорируется.
|
||||||
|
const handleRemoveApprover = (row: IMatrixRow): void => {
|
||||||
|
if (!row.id) return;
|
||||||
|
setRouteError(undefined);
|
||||||
|
approverService
|
||||||
|
.deleteQueuedStep(itemId, row.id)
|
||||||
|
.then(() => setDecisionSeq((n) => n + 1))
|
||||||
|
.catch((e) => setRouteError(friendlyErrorMessage(e)));
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -150,7 +224,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sectionProps = { data, set: NOOP_SET, context };
|
const sectionProps = { data, set, context, payrollEditable };
|
||||||
|
|
||||||
const notify = (): void => {
|
const notify = (): void => {
|
||||||
if (!requestTypeId) return;
|
if (!requestTypeId) return;
|
||||||
@@ -182,6 +256,21 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
{isApprovedStatus(status) && <PrintButton />}
|
{isApprovedStatus(status) && <PrintButton />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{payrollEditable && (
|
||||||
|
<div className={`${styles.reviewBar} rp-no-print`}>
|
||||||
|
<span className={styles.reviewHint}>
|
||||||
|
Как согласующий вы можете указать оклады (п. 4.2, 4.3) и отдел/участок (п. 2.5).
|
||||||
|
</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">
|
<div className="rp-print-root">
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Заявка на прием"
|
title="Заявка на прием"
|
||||||
@@ -227,7 +316,13 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
</div>
|
</div>
|
||||||
</FormCard>
|
</FormCard>
|
||||||
|
|
||||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
{routeError && <Alert variant="err">{routeError}</Alert>}
|
||||||
|
<ApprovalMatrix
|
||||||
|
editor={approvers}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
canRemoveRow={canRemoveApprover ? (row) => row.status === REQUEST_STATUS.queued : undefined}
|
||||||
|
onRemoveRow={handleRemoveApprover}
|
||||||
|
/>
|
||||||
|
|
||||||
{!executors.loading && executors.rows.length > 0 && (
|
{!executors.loading && executors.rows.length > 0 && (
|
||||||
<ApprovalMatrix
|
<ApprovalMatrix
|
||||||
@@ -246,6 +341,9 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
service={approverService}
|
service={approverService}
|
||||||
requestId={itemId}
|
requestId={itemId}
|
||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
// Оклады сохраняются вместе с решением — иначе согласующий, забывший
|
||||||
|
// нажать «Сохранить», согласовал бы заявку со старыми значениями.
|
||||||
|
onBeforeDecide={payrollEditable ? saveApproverFields : undefined}
|
||||||
onDecided={(newStatus) => {
|
onDecided={(newStatus) => {
|
||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Кто из согласующих какие поля заявки «на прием» может править на своём шаге.
|
||||||
|
*
|
||||||
|
* Инициатор указывает оклады предварительно — фактические суммы (после
|
||||||
|
* калькуляции по грейду/ставке) утверждает начальник расчётной группы на
|
||||||
|
* своём шаге согласования (см. ClearanceView.approverRights.ts — тот же приём).
|
||||||
|
*
|
||||||
|
* Должность берётся из строки согласования (колонка Title, см.
|
||||||
|
* IApprovalRow.position), то есть та, что подставил маршрут при планировании.
|
||||||
|
* Сравнение — по ключевым словам, а не по точной строке: формулировки в
|
||||||
|
* справочнике различаются («Начальник…», «Руководитель…»).
|
||||||
|
*
|
||||||
|
* ВАЖНО про требование управленческого маркера (начальник/руководитель): без
|
||||||
|
* него право получили бы и рядовые сотрудники расчётной группы, оказавшиеся
|
||||||
|
* согласующими на любом другом шаге.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MANAGER_MARKERS = ['начальник', 'руководитель'];
|
||||||
|
|
||||||
|
/** Расчётная группа (бухгалтерия/payroll) — правит оклады на ИС/после ИС и отдел/участок. */
|
||||||
|
const PAYROLL_MARKERS = ['расчет'];
|
||||||
|
|
||||||
|
function normalize(position: string | undefined): string {
|
||||||
|
return (position || '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesAll(position: string, markers: string[]): boolean {
|
||||||
|
return markers.every((m) => position.indexOf(m) !== -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isManagerOf(position: string | undefined, departmentMarkers: string[]): boolean {
|
||||||
|
const normalized = normalize(position);
|
||||||
|
if (!normalized) return false;
|
||||||
|
return (
|
||||||
|
MANAGER_MARKERS.some((m) => normalized.indexOf(m) !== -1) && matchesAll(normalized, departmentMarkers)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Оклад на ИС, оклад после ИС, отдел/участок — начальник расчётной группы. */
|
||||||
|
export function canEditPayrollFields(position: string | undefined): boolean {
|
||||||
|
return isManagerOf(position, PAYROLL_MARKERS);
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ const SCALAR_FIELDS: (keyof IFormData)[] = [
|
|||||||
'MainCompanyId',
|
'MainCompanyId',
|
||||||
'Phone', 'HireType', 'HireReplacedEmployee', 'Project', 'ProjectId', 'ProjectCode',
|
'Phone', 'HireType', 'HireReplacedEmployee', 'Project', 'ProjectId', 'ProjectCode',
|
||||||
'HireCandidateName', 'HireHireDate', 'Position', 'WorkKind',
|
'HireCandidateName', 'HireHireDate', 'Position', 'WorkKind',
|
||||||
'Department', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod',
|
'Department', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod', 'ProbationPeriodOther',
|
||||||
'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther',
|
'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther',
|
||||||
'AgencyName', 'AgencyTerms',
|
'AgencyName', 'AgencyTerms',
|
||||||
'SelectionRequestId', 'SelectionRequestListId', 'SelectionRequestCode'
|
'SelectionRequestId', 'SelectionRequestListId', 'SelectionRequestCode'
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export interface IFormData {
|
|||||||
PpeShoeSize: string;
|
PpeShoeSize: string;
|
||||||
// Условия оплаты и оформления
|
// Условия оплаты и оформления
|
||||||
ProbationPeriod: string;
|
ProbationPeriod: string;
|
||||||
|
ProbationPeriodOther: string;
|
||||||
SalaryProbation: string;
|
SalaryProbation: string;
|
||||||
SalaryAfterProbation: string;
|
SalaryAfterProbation: string;
|
||||||
WorkSchedule: string;
|
WorkSchedule: string;
|
||||||
@@ -105,6 +106,7 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
PpeClothingSize: '',
|
PpeClothingSize: '',
|
||||||
PpeShoeSize: '',
|
PpeShoeSize: '',
|
||||||
ProbationPeriod: '',
|
ProbationPeriod: '',
|
||||||
|
ProbationPeriodOther: '',
|
||||||
SalaryProbation: '',
|
SalaryProbation: '',
|
||||||
SalaryAfterProbation: '',
|
SalaryAfterProbation: '',
|
||||||
WorkSchedule: '',
|
WorkSchedule: '',
|
||||||
@@ -123,17 +125,18 @@ export const HIRE_TYPE_OPTIONS: ISelectOption[] = [
|
|||||||
{ value: HIRE_TYPE_REPLACEMENT, label: HIRE_TYPE_REPLACEMENT }
|
{ value: HIRE_TYPE_REPLACEMENT, label: HIRE_TYPE_REPLACEMENT }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Значение-триггер «Другое»: при нём показываем поле ручного ввода. */
|
||||||
|
export const OTHER_VALUE = 'Другое';
|
||||||
|
|
||||||
/** Варианты «Испытательный срок» — самые частые сроки (совпадают с choices колонки ProbationPeriod). */
|
/** Варианты «Испытательный срок» — самые частые сроки (совпадают с choices колонки ProbationPeriod). */
|
||||||
export const PROBATION_PERIOD_OPTIONS: ISelectOption[] = [
|
export const PROBATION_PERIOD_OPTIONS: ISelectOption[] = [
|
||||||
{ value: '1 месяц', label: '1 месяц' },
|
{ value: '1 месяц', label: '1 месяц' },
|
||||||
{ value: '2 месяца', label: '2 месяца' },
|
{ value: '2 месяца', label: '2 месяца' },
|
||||||
{ value: '3 месяца', label: '3 месяца' },
|
{ value: '3 месяца', label: '3 месяца' },
|
||||||
{ value: '6 месяцев', label: '6 месяцев' }
|
{ value: '6 месяцев', label: '6 месяцев' },
|
||||||
|
{ value: OTHER_VALUE, label: OTHER_VALUE }
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Значение-триггер «Другое»: при нём показываем поле ручного ввода графика. */
|
|
||||||
export const OTHER_VALUE = 'Другое';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Варианты «График работы» — совпадают с набором в форме «Заявка на подбор»
|
* Варианты «График работы» — совпадают с набором в форме «Заявка на подбор»
|
||||||
* (WORK_SCHEDULE_OPTIONS): формы независимы, значения продублированы намеренно.
|
* (WORK_SCHEDULE_OPTIONS): формы независимы, значения продублированы намеренно.
|
||||||
@@ -141,10 +144,10 @@ export const OTHER_VALUE = 'Другое';
|
|||||||
export const WORK_SCHEDULE_OPTIONS: ISelectOption[] = [
|
export const WORK_SCHEDULE_OPTIONS: ISelectOption[] = [
|
||||||
{ value: '5/2', label: '5/2' },
|
{ value: '5/2', label: '5/2' },
|
||||||
{ value: 'Вахтовый 30/30', label: 'Вахтовый 30/30' },
|
{ value: 'Вахтовый 30/30', label: 'Вахтовый 30/30' },
|
||||||
{ value: '20/10', label: '20/10' },
|
{ value: 'Вахтовый 20/10', label: 'Вахтовый 20/10' },
|
||||||
{ value: '30/15', label: '30/15' },
|
{ value: 'Вахтовый 30/15', label: 'Вахтовый 30/15' },
|
||||||
{ value: '22/8', label: '22/8' },
|
{ value: 'Вахтовый 22/8', label: 'Вахтовый 22/8' },
|
||||||
{ value: '15/15', label: '15/15' },
|
{ value: 'Вахтовый 15/15', label: 'Вахтовый 15/15' },
|
||||||
{ value: '6/1', label: '6/1' },
|
{ value: '6/1', label: '6/1' },
|
||||||
{ value: '1/2', label: '1/2' },
|
{ value: '1/2', label: '1/2' },
|
||||||
{ value: '2/2', label: '2/2' },
|
{ value: '2/2', label: '2/2' },
|
||||||
|
|||||||
@@ -33,7 +33,8 @@
|
|||||||
{ "internalName": "PpeClothingSize", "title": "СИЗ: размер одежды", "type": "Text" },
|
{ "internalName": "PpeClothingSize", "title": "СИЗ: размер одежды", "type": "Text" },
|
||||||
{ "internalName": "PpeShoeSize", "title": "СИЗ: размер обуви", "type": "Number" },
|
{ "internalName": "PpeShoeSize", "title": "СИЗ: размер обуви", "type": "Number" },
|
||||||
|
|
||||||
{ "internalName": "ProbationPeriod", "title": "Испытательный срок", "type": "Choice", "choices": ["1 месяц", "2 месяца", "3 месяца", "6 месяцев"] },
|
{ "internalName": "ProbationPeriod", "title": "Испытательный срок", "type": "Choice", "choices": ["1 месяц", "2 месяца", "3 месяца", "6 месяцев", "Другое"] },
|
||||||
|
{ "internalName": "ProbationPeriodOther", "title": "Испытательный срок (другое)", "type": "Text" },
|
||||||
{ "internalName": "SalaryProbation", "title": "Оклад при испытательном сроке", "type": "Number", "decimals": 2 },
|
{ "internalName": "SalaryProbation", "title": "Оклад при испытательном сроке", "type": "Number", "decimals": 2 },
|
||||||
{ "internalName": "SalaryAfterProbation", "title": "Оклад по завершении ИС", "type": "Number", "decimals": 2 },
|
{ "internalName": "SalaryAfterProbation", "title": "Оклад по завершении ИС", "type": "Number", "decimals": 2 },
|
||||||
{ "internalName": "WorkSchedule", "title": "График работы", "type": "Text" },
|
{ "internalName": "WorkSchedule", "title": "График работы", "type": "Text" },
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CollapseSection title="1. Заявка" icon="clipboard">
|
<CollapseSection title="1. Заявка" icon="clipboard">
|
||||||
<FormField label="1.1 Организация" htmlFor="Companies" full>
|
<FormField label="1.1 Заявка на подбор" full>
|
||||||
|
<SelectionRequestField data={data} set={set} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="1.2 Организация" htmlFor="Companies" required full>
|
||||||
<CompanyPickerMulti
|
<CompanyPickerMulti
|
||||||
id="Companies"
|
id="Companies"
|
||||||
context={context}
|
context={context}
|
||||||
@@ -49,7 +52,7 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
|
|||||||
shares={data.CompanyShares}
|
shares={data.CompanyShares}
|
||||||
onSharesChange={(v) => set('CompanyShares', v)}
|
onSharesChange={(v) => set('CompanyShares', v)}
|
||||||
/>
|
/>
|
||||||
<FormField label="1.2 Проект" htmlFor="Project" required full>
|
<FormField label="1.3 Проект" htmlFor="Project" required full>
|
||||||
<ProjectPicker
|
<ProjectPicker
|
||||||
context={context}
|
context={context}
|
||||||
id="Project"
|
id="Project"
|
||||||
@@ -62,13 +65,10 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
|
|||||||
placeholder="Начните вводить название или код проекта…"
|
placeholder="Начните вводить название или код проекта…"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="1.3 Заявка на подбор" full>
|
<FormField label="1.4 Телефон" htmlFor="Phone" required>
|
||||||
<SelectionRequestField data={data} set={set} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="1.4 Телефон" htmlFor="Phone">
|
|
||||||
<PhoneInput id="Phone" value={data.Phone} onChange={(v) => set('Phone', v)} />
|
<PhoneInput id="Phone" value={data.Phone} onChange={(v) => set('Phone', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="1.5 Тип приёма" htmlFor="HireType">
|
<FormField label="1.5 Тип приёма" htmlFor="HireType" required>
|
||||||
<Select
|
<Select
|
||||||
id="HireType"
|
id="HireType"
|
||||||
value={data.HireType}
|
value={data.HireType}
|
||||||
@@ -78,7 +78,11 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
{isReplacement && (
|
{isReplacement && (
|
||||||
<FormField label="Взамен кого (Ф.И.О. уволенного)" htmlFor="HireReplacedEmployee" full>
|
<FormField
|
||||||
|
label="Взамен кого (Ф.И.О. уволенного, полностью, пример: Иванов Иван Иванович)"
|
||||||
|
htmlFor="HireReplacedEmployee"
|
||||||
|
full
|
||||||
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="HireReplacedEmployee"
|
id="HireReplacedEmployee"
|
||||||
value={data.HireReplacedEmployee}
|
value={data.HireReplacedEmployee}
|
||||||
|
|||||||
@@ -1,33 +1,70 @@
|
|||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { CollapseSection, FormField, TextInput } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput } from "../../../shared/ui";
|
||||||
import { PositionPicker } from '../../../shared/dictionary';
|
import { PositionPicker } from "../../../shared/dictionary";
|
||||||
import { ISectionProps } from '../model';
|
import { ISectionProps } from "../model";
|
||||||
|
|
||||||
export const CandidateSection: React.FC<ISectionProps> = ({ data, set, context }) => (
|
interface ICandidateSectionProps extends ISectionProps {
|
||||||
|
/** Начальник расчётной группы правит отдел/участок даже в режиме просмотра (см. approverRights.ts). */
|
||||||
|
payrollEditable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CandidateSection: React.FC<ICandidateSectionProps> = ({
|
||||||
|
data,
|
||||||
|
set,
|
||||||
|
context,
|
||||||
|
payrollEditable,
|
||||||
|
}) => (
|
||||||
<CollapseSection title="2. Кандидат" icon="people">
|
<CollapseSection title="2. Кандидат" icon="people">
|
||||||
<FormField label="2.1 Ф.И.О. кандидата" htmlFor="HireCandidateName" required full>
|
<FormField
|
||||||
<TextInput id="HireCandidateName" value={data.HireCandidateName} onChange={(v) => set('HireCandidateName', v)} />
|
label="2.1 Ф.И.О. кандидата (Полностью, пример: Иванов Иван Иванович)"
|
||||||
|
htmlFor="HireCandidateName"
|
||||||
|
required
|
||||||
|
full
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
id="HireCandidateName"
|
||||||
|
value={data.HireCandidateName}
|
||||||
|
onChange={(v) => set("HireCandidateName", v)}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="2.2 Дата приёма" htmlFor="HireHireDate">
|
<FormField label="2.2 Дата приёма" htmlFor="HireHireDate" required>
|
||||||
<TextInput id="HireHireDate" type="date" value={data.HireHireDate} onChange={(v) => set('HireHireDate', v)} />
|
<TextInput
|
||||||
|
id="HireHireDate"
|
||||||
|
type="date"
|
||||||
|
value={data.HireHireDate}
|
||||||
|
onChange={(v) => set("HireHireDate", v)}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="2.3 Должность" htmlFor="Position" required>
|
<FormField label="2.3 Должность" htmlFor="Position" required>
|
||||||
<PositionPicker
|
<PositionPicker
|
||||||
context={context}
|
context={context}
|
||||||
id="Position"
|
id="Position"
|
||||||
value={data.Position}
|
value={data.Position}
|
||||||
onChange={(v) => set('Position', v)}
|
onChange={(v) => set("Position", v)}
|
||||||
placeholder="Начните вводить название должности…"
|
placeholder="Начните вводить название должности…"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="2.4 Вид работ" htmlFor="WorkKind">
|
<FormField label="2.4 Вид работ" htmlFor="WorkKind">
|
||||||
<TextInput id="WorkKind" value={data.WorkKind} onChange={(v) => set('WorkKind', v)} />
|
<TextInput
|
||||||
|
id="WorkKind"
|
||||||
|
value={data.WorkKind}
|
||||||
|
onChange={(v) => set("WorkKind", v)}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="2.5 Отдел/Участок" htmlFor="Department">
|
<FormField label="2.5 Отдел/Участок" htmlFor="Department">
|
||||||
<TextInput id="Department" value={data.Department} onChange={(v) => set('Department', v)} />
|
<TextInput
|
||||||
|
id="Department"
|
||||||
|
value={data.Department}
|
||||||
|
onChange={(v) => set("Department", v)}
|
||||||
|
readOnly={payrollEditable ? false : undefined}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="2.6 Местоположение" htmlFor="SiteLocation">
|
<FormField label="2.6 Местоположение" htmlFor="SiteLocation">
|
||||||
<TextInput id="SiteLocation" value={data.SiteLocation} onChange={(v) => set('SiteLocation', v)} />
|
<TextInput
|
||||||
|
id="SiteLocation"
|
||||||
|
value={data.SiteLocation}
|
||||||
|
onChange={(v) => set("SiteLocation", v)}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</CollapseSection>
|
</CollapseSection>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,32 +2,51 @@ import * as React from 'react';
|
|||||||
import { CollapseSection, FormField, TextInput, MoneyInput, Select } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput, MoneyInput, Select } from '../../../shared/ui';
|
||||||
import { ISectionProps, OTHER_VALUE, WORK_SCHEDULE_OPTIONS, PROBATION_PERIOD_OPTIONS } from '../model';
|
import { ISectionProps, OTHER_VALUE, WORK_SCHEDULE_OPTIONS, PROBATION_PERIOD_OPTIONS } from '../model';
|
||||||
|
|
||||||
export const ConditionsSection: React.FC<ISectionProps> = ({ data, set }) => (
|
interface IConditionsSectionProps extends ISectionProps {
|
||||||
|
/** Начальник расчётной группы правит оклады даже в режиме просмотра (см. approverRights.ts). */
|
||||||
|
payrollEditable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConditionsSection: React.FC<IConditionsSectionProps> = ({ data, set, payrollEditable }) => (
|
||||||
<CollapseSection title="4. Условия оплаты и оформления" icon="calculator">
|
<CollapseSection title="4. Условия оплаты и оформления" icon="calculator">
|
||||||
<FormField label="4.1 Испытательный срок" htmlFor="ProbationPeriod">
|
<FormField label="4.1 Испытательный срок" htmlFor="ProbationPeriod" required>
|
||||||
<Select
|
<Select
|
||||||
id="ProbationPeriod"
|
id="ProbationPeriod"
|
||||||
value={data.ProbationPeriod}
|
value={data.ProbationPeriod}
|
||||||
onChange={(v) => set('ProbationPeriod', v)}
|
onChange={(v) => {
|
||||||
|
set('ProbationPeriod', v);
|
||||||
|
if (v !== OTHER_VALUE) set('ProbationPeriodOther', '');
|
||||||
|
}}
|
||||||
options={PROBATION_PERIOD_OPTIONS}
|
options={PROBATION_PERIOD_OPTIONS}
|
||||||
placeholder="— выберите —"
|
placeholder="— выберите —"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="4.2 Оклад при испытательном сроке" htmlFor="SalaryProbation">
|
{data.ProbationPeriod === OTHER_VALUE && (
|
||||||
|
<FormField label="Испытательный срок (другое)" htmlFor="ProbationPeriodOther">
|
||||||
|
<TextInput
|
||||||
|
id="ProbationPeriodOther"
|
||||||
|
value={data.ProbationPeriodOther}
|
||||||
|
onChange={(v) => set('ProbationPeriodOther', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
<FormField label="4.2 Оклад при испытательном сроке" htmlFor="SalaryProbation" required>
|
||||||
<MoneyInput
|
<MoneyInput
|
||||||
id="SalaryProbation"
|
id="SalaryProbation"
|
||||||
value={data.SalaryProbation}
|
value={data.SalaryProbation}
|
||||||
onChange={(v) => set('SalaryProbation', v)}
|
onChange={(v) => set('SalaryProbation', v)}
|
||||||
|
readOnly={payrollEditable ? false : undefined}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="4.3 Оклад по завершении ИС" htmlFor="SalaryAfterProbation">
|
<FormField label="4.3 Оклад по завершении ИС" htmlFor="SalaryAfterProbation" required>
|
||||||
<MoneyInput
|
<MoneyInput
|
||||||
id="SalaryAfterProbation"
|
id="SalaryAfterProbation"
|
||||||
value={data.SalaryAfterProbation}
|
value={data.SalaryAfterProbation}
|
||||||
onChange={(v) => set('SalaryAfterProbation', v)}
|
onChange={(v) => set('SalaryAfterProbation', v)}
|
||||||
|
readOnly={payrollEditable ? false : undefined}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="4.4 График работы" htmlFor="WorkSchedule">
|
<FormField label="4.4 График работы" htmlFor="WorkSchedule" required>
|
||||||
<Select
|
<Select
|
||||||
id="WorkSchedule"
|
id="WorkSchedule"
|
||||||
value={data.WorkSchedule}
|
value={data.WorkSchedule}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import styles from './SelectionRequestField.module.scss';
|
|||||||
import { useFormReadOnly } from '../../../shared/ui';
|
import { useFormReadOnly } from '../../../shared/ui';
|
||||||
import { routeUrl } from '../../../shared/routing';
|
import { routeUrl } from '../../../shared/routing';
|
||||||
import { getApprovedSelectionRequests, ISelectionRequestOption } from '../selectionLookup';
|
import { getApprovedSelectionRequests, ISelectionRequestOption } from '../selectionLookup';
|
||||||
|
import { loadSelectionRequest } from '../../RecruitingSelection/load';
|
||||||
import { IFormData, SetField } from '../model';
|
import { IFormData, SetField } from '../model';
|
||||||
|
|
||||||
/** Слаг формы подбора в реестре форм — цель ссылки (rptype). */
|
/** Слаг формы подбора в реестре форм — цель ссылки (rptype). */
|
||||||
@@ -83,6 +84,26 @@ export const SelectionRequestField: React.FC<ISelectionRequestFieldProps> = ({ d
|
|||||||
setEditing(false);
|
setEditing(false);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setQuery('');
|
setQuery('');
|
||||||
|
|
||||||
|
// Максимум полей, общих с заявкой на подбор, подтягиваем из неё — перезаписываем
|
||||||
|
// даже уже заполненные вручную (пользователь осознанно выбрал/сменил связь, см.
|
||||||
|
// обсуждение с Тимуром: перезапись — ожидаемое поведение при смене связанной заявки).
|
||||||
|
loadSelectionRequest(o.id, o.listId || undefined)
|
||||||
|
.then(({ data: selection }) => {
|
||||||
|
set('Companies', selection.Companies);
|
||||||
|
set('MainCompanyId', ''); // компании заменены целиком — зависимые поля пересчитывать не от чего
|
||||||
|
set('CompanyShares', []);
|
||||||
|
set('Project', selection.ObjectProject);
|
||||||
|
set('ProjectId', selection.ObjectProjectId);
|
||||||
|
set('ProjectCode', selection.ObjectProjectCode);
|
||||||
|
set('Position', selection.Position);
|
||||||
|
set('Department', selection.Department);
|
||||||
|
set('WorkSchedule', selection.WorkSchedule);
|
||||||
|
set('WorkScheduleOther', selection.WorkScheduleOther);
|
||||||
|
set('SalaryProbation', selection.RateProbation);
|
||||||
|
set('SalaryAfterProbation', selection.RateAfterProbation);
|
||||||
|
})
|
||||||
|
.catch(() => undefined); // связь уже сохранена по id/коду — сбой обогащения не критичен
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClear = (): void => {
|
const handleClear = (): void => {
|
||||||
@@ -144,7 +165,7 @@ export const SelectionRequestField: React.FC<ISelectionRequestFieldProps> = ({ d
|
|||||||
<input
|
<input
|
||||||
className={styles.input}
|
className={styles.input}
|
||||||
value={query}
|
value={query}
|
||||||
placeholder="Начните вводить код, должность или название…"
|
placeholder="Начните вводить номер, должность или название…"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setQuery(e.target.value);
|
setQuery(e.target.value);
|
||||||
ensureLoaded();
|
ensureLoaded();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
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';
|
import { FORM_ID, IFormData } from './model';
|
||||||
|
|
||||||
// Числовые поля: пустая строка → null, иначе Number().
|
// Числовые поля: пустая строка → null, иначе Number().
|
||||||
@@ -47,6 +47,38 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
|||||||
|
|
||||||
export type ISaveResult = ISaveItemResult;
|
export type ISaveResult = ISaveItemResult;
|
||||||
|
|
||||||
|
/** Поля, которые начальник расчётной группы правит на своём шаге (см. approverRights.ts). */
|
||||||
|
export interface IApproverEditableFields {
|
||||||
|
SalaryProbation: string;
|
||||||
|
SalaryAfterProbation: string;
|
||||||
|
Department: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить только поля, что заполняет согласующий расчётной группы на своём
|
||||||
|
* шаге. Пишутся ровно переданные поля — как в Clearance.updateApproverFields,
|
||||||
|
* чтобы не затирать остальную заявку значениями из уже устаревшего снимка формы.
|
||||||
|
*/
|
||||||
|
export async function updateApproverFields(
|
||||||
|
id: number,
|
||||||
|
fields: Partial<IApproverEditableFields>,
|
||||||
|
listId?: string
|
||||||
|
): Promise<void> {
|
||||||
|
const item: Record<string, unknown> = {};
|
||||||
|
if (fields.SalaryProbation !== undefined) {
|
||||||
|
item.SalaryProbation = fields.SalaryProbation === '' ? null : Number(fields.SalaryProbation);
|
||||||
|
}
|
||||||
|
if (fields.SalaryAfterProbation !== undefined) {
|
||||||
|
item.SalaryAfterProbation = fields.SalaryAfterProbation === '' ? null : Number(fields.SalaryAfterProbation);
|
||||||
|
}
|
||||||
|
if (fields.Department !== undefined) {
|
||||||
|
item.Department = fields.Department === '' ? null : fields.Department;
|
||||||
|
}
|
||||||
|
if (Object.keys(item).length === 0) return;
|
||||||
|
const list = resolveDataList(FORM_ID, listId);
|
||||||
|
await list.items.getById(id).update(item);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохранить заявку «на прием»: контент + маршрут (согласующие + исполнители) со статусом.
|
* Сохранить заявку «на прием»: контент + маршрут (согласующие + исполнители) со статусом.
|
||||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { IFormData } from './model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поля формы, обязательные для ОТПРАВКИ на согласование (черновик по-прежнему
|
||||||
|
* можно сохранить не заполнив их). Исключения по требованию заказчика: 2.4
|
||||||
|
* «Вид работ» (WorkKind), 2.5 «Отдел/Участок» (Department), 2.6
|
||||||
|
* «Местоположение» (SiteLocation), 3.1–3.3 СИЗ (PpeHeight/PpeClothingSize/
|
||||||
|
* PpeShoeSize). 1.1 «Заявка на подбор» оставлена опциональной — приём иногда
|
||||||
|
* идёт через рекрутинговое агентство (см. AgencySection), без внутреннего
|
||||||
|
* подбора. Toggle-поле 5.1 «Рекрутинговое агентство» в проверку не входит — у
|
||||||
|
* булевых нет пустого состояния.
|
||||||
|
*/
|
||||||
|
const REQUIRED_FIELDS: ReadonlyArray<{ label: string; ok: (d: IFormData) => boolean }> = [
|
||||||
|
{ label: '1.2 организация', ok: (d) => d.Companies.length > 0 },
|
||||||
|
{ label: '1.4 телефон', ok: (d) => !!d.Phone.trim() },
|
||||||
|
{ label: '1.5 тип приёма', ok: (d) => !!d.HireType },
|
||||||
|
{ label: '2.2 дата приёма', ok: (d) => !!d.HireHireDate },
|
||||||
|
{ label: '4.1 испытательный срок', ok: (d) => !!d.ProbationPeriod },
|
||||||
|
{ label: '4.2 оклад при испытательном сроке', ok: (d) => !!d.SalaryProbation.trim() },
|
||||||
|
{ label: '4.3 оклад по завершении ИС', ok: (d) => !!d.SalaryAfterProbation.trim() },
|
||||||
|
{ label: '4.4 график работы', ok: (d) => !!d.WorkSchedule }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Подписи незаполненных обязательных-для-отправки полей (пусто — всё заполнено). */
|
||||||
|
export function missingRequiredFields(data: IFormData): string[] {
|
||||||
|
return REQUIRED_FIELDS.filter((f) => !f.ok(data)).map((f) => f.label);
|
||||||
|
}
|
||||||
@@ -26,7 +26,9 @@ import {
|
|||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
IApproverToWrite,
|
IApproverToWrite,
|
||||||
unassignedStepMessage
|
unassignedStepMessage,
|
||||||
|
resolveApproverOrgInfo,
|
||||||
|
canEditRoute
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import {
|
import {
|
||||||
uploadAttachments,
|
uploadAttachments,
|
||||||
@@ -45,6 +47,7 @@ import {
|
|||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} from '../../shared/listTitles';
|
} from '../../shared/listTitles';
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField, todayIso } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField, todayIso } from './model';
|
||||||
|
import { missingRequiredFields } from './validation';
|
||||||
import { saveSelectionRequest, selectionApprovalService } from './submit';
|
import { saveSelectionRequest, selectionApprovalService } from './submit';
|
||||||
import { loadSelectionRequest } from './load';
|
import { loadSelectionRequest } from './load';
|
||||||
import { ApplicationSection } from './sections/ApplicationSection';
|
import { ApplicationSection } from './sections/ApplicationSection';
|
||||||
@@ -169,6 +172,9 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
|||||||
// selectionApprovalService можно звать не опасаясь исключения.
|
// selectionApprovalService можно звать не опасаясь исключения.
|
||||||
const approval = useMemo(() => selectionApprovalService(editListId), [editListId]);
|
const approval = useMemo(() => selectionApprovalService(editListId), [editListId]);
|
||||||
const userEmail = effectiveUserEmail(context);
|
const userEmail = effectiveUserEmail(context);
|
||||||
|
// Право удалять согласующих из маршрута ещё на этапе составления заявки —
|
||||||
|
// см. shared/approval/routeEditRights.ts (сейчас — Алтынай Бултурукова).
|
||||||
|
const canRemoveApprover = canEditRoute(userEmail);
|
||||||
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
|
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
|
||||||
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
|
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
|
||||||
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
||||||
@@ -204,7 +210,8 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
|||||||
`${userEmail}-${editId ?? 'new'}-${projectId}-${companiesFieldValue}`,
|
`${userEmail}-${editId ?? 'new'}-${projectId}-${companiesFieldValue}`,
|
||||||
!readOnly,
|
!readOnly,
|
||||||
matrixReady,
|
matrixReady,
|
||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login),
|
||||||
|
resolveApproverOrgInfo(context)
|
||||||
);
|
);
|
||||||
|
|
||||||
// ObjectProjectId обязателен (а не просто текст ObjectProject): маршрут
|
// ObjectProjectId обязателен (а не просто текст ObjectProject): маршрут
|
||||||
@@ -229,6 +236,11 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Остальные поля формы обязательны только для ОТПРАВКИ на согласование —
|
||||||
|
// черновик по-прежнему можно сохранить не заполнив их (см. validation.ts).
|
||||||
|
const missingFields = missingRequiredFields(data);
|
||||||
|
const isSubmitValid = isValid && missingFields.length === 0;
|
||||||
|
|
||||||
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
||||||
rows.map((r) => ({
|
rows.map((r) => ({
|
||||||
email: r.email,
|
email: r.email,
|
||||||
@@ -306,6 +318,9 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
|||||||
const validateSubmit = (): string | undefined => {
|
const validateSubmit = (): string | undefined => {
|
||||||
const formError = validate();
|
const formError = validate();
|
||||||
if (formError) return formError;
|
if (formError) return formError;
|
||||||
|
if (missingFields.length > 0) {
|
||||||
|
return `Заполните обязательные поля: ${missingFields.join(', ')}.`;
|
||||||
|
}
|
||||||
if (matrix.error) {
|
if (matrix.error) {
|
||||||
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
||||||
}
|
}
|
||||||
@@ -389,11 +404,13 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
|||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
waiting={!matrixReady}
|
waiting={!matrixReady}
|
||||||
waitingText="Выберите «Объект/Проект» (п. 1.7), чтобы построить маршрут согласования."
|
waitingText="Выберите «Объект/Проект» (п. 1.7), чтобы построить маршрут согласования."
|
||||||
|
canRemoveRow={canRemoveApprover ? () => true : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SubmitActions
|
<SubmitActions
|
||||||
submit={submit}
|
submit={submit}
|
||||||
isValid={isValid}
|
isValid={isValid}
|
||||||
|
submitValid={isSubmitValid}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
onSaveDraft={handleSaveDraft}
|
onSaveDraft={handleSaveDraft}
|
||||||
|
|||||||
@@ -11,9 +11,14 @@ import {
|
|||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
isApprovedStatus
|
isApprovedStatus,
|
||||||
|
resolveApproverOrgInfo,
|
||||||
|
canEditRoute,
|
||||||
|
REQUEST_STATUS,
|
||||||
|
IMatrixRow
|
||||||
} 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 { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
import {
|
import {
|
||||||
approvalListTitle,
|
approvalListTitle,
|
||||||
@@ -59,6 +64,11 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
|
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);
|
||||||
|
// Право удалять согласующих из уже поданного маршрута — см.
|
||||||
|
// shared/approval/routeEditRights.ts (сейчас — Алтынай Бултурукова). Не
|
||||||
|
// привязано к активному шагу — не зависит от того, чья сейчас очередь.
|
||||||
|
const canRemoveApprover = canEditRoute(effectiveUserEmail(context));
|
||||||
|
const [routeError, setRouteError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
|
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
|
||||||
// selectionApprovalService можно звать не опасаясь исключения.
|
// selectionApprovalService можно звать не опасаясь исключения.
|
||||||
@@ -74,9 +84,19 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
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), [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), resolveApproverOrgInfo(context)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Удалить ещё не решённый шаг маршрута (см. ApproverService.deleteQueuedStep).
|
||||||
|
const handleRemoveApprover = (row: IMatrixRow): void => {
|
||||||
|
if (!row.id) return;
|
||||||
|
setRouteError(undefined);
|
||||||
|
approverService
|
||||||
|
.deleteQueuedStep(itemId, row.id)
|
||||||
|
.then(() => setDecisionSeq((n) => n + 1))
|
||||||
|
.catch((e) => setRouteError(friendlyErrorMessage(e)));
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -160,7 +180,13 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
</div>
|
</div>
|
||||||
</FormCard>
|
</FormCard>
|
||||||
|
|
||||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
{routeError && <Alert variant="err">{routeError}</Alert>}
|
||||||
|
<ApprovalMatrix
|
||||||
|
editor={approvers}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
canRemoveRow={canRemoveApprover ? (row) => row.status === REQUEST_STATUS.queued : undefined}
|
||||||
|
onRemoveRow={handleRemoveApprover}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||||
|
|||||||
+7
-5
@@ -14,7 +14,9 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
|
|||||||
const reason = data.VacancyOpenReason;
|
const reason = data.VacancyOpenReason;
|
||||||
const showReasonDetails = reason !== '' && reason !== OPEN_REASON_NEW;
|
const showReasonDetails = reason !== '' && reason !== OPEN_REASON_NEW;
|
||||||
const reasonDetailsLabel =
|
const reasonDetailsLabel =
|
||||||
reason === OPEN_REASON_REPLACE || reason === OPEN_REASON_TRANSFER ? 'ФИО сотрудника' : 'Уточните причину';
|
reason === OPEN_REASON_REPLACE || reason === OPEN_REASON_TRANSFER
|
||||||
|
? 'ФИО сотрудника (Полностью, пример: Иванов Иван Иванович)'
|
||||||
|
: 'Уточните причину';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollapseSection title="1. Заявка" icon="clipboard">
|
<CollapseSection title="1. Заявка" icon="clipboard">
|
||||||
@@ -34,10 +36,10 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
|
|||||||
placeholder="Подставится автоматически по выбранному ответственному"
|
placeholder="Подставится автоматически по выбранному ответственному"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="1.3 Дата подачи заявки" htmlFor="SubmitDate">
|
<FormField label="1.3 Дата подачи заявки" htmlFor="SubmitDate" required>
|
||||||
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} readOnly />
|
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} readOnly />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="1.4 Крайний срок закрытия" htmlFor="ClosingDeadline">
|
<FormField label="1.4 Крайний срок закрытия" htmlFor="ClosingDeadline" required>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="ClosingDeadline"
|
id="ClosingDeadline"
|
||||||
type="date"
|
type="date"
|
||||||
@@ -46,7 +48,7 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
|
|||||||
min={data.SubmitDate || undefined}
|
min={data.SubmitDate || undefined}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="1.5 Причина открытия вакансии" htmlFor="VacancyOpenReason">
|
<FormField label="1.5 Причина открытия вакансии" htmlFor="VacancyOpenReason" required>
|
||||||
<Select
|
<Select
|
||||||
id="VacancyOpenReason"
|
id="VacancyOpenReason"
|
||||||
value={data.VacancyOpenReason}
|
value={data.VacancyOpenReason}
|
||||||
@@ -67,7 +69,7 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
<FormField label="1.6 Организация" htmlFor="Companies" full>
|
<FormField label="1.6 Организация" htmlFor="Companies" required full>
|
||||||
<CompanyPickerMulti
|
<CompanyPickerMulti
|
||||||
context={context}
|
context={context}
|
||||||
id="Companies"
|
id="Companies"
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ export const ConditionsSection: React.FC<ISectionProps> = ({
|
|||||||
context,
|
context,
|
||||||
}) => (
|
}) => (
|
||||||
<CollapseSection title="5. Условия работы" icon="chart">
|
<CollapseSection title="5. Условия работы" icon="chart">
|
||||||
<FormField label="5.1 Ставка на ИС" htmlFor="RateProbation">
|
<FormField label="5.1 Ставка на ИС" htmlFor="RateProbation" required>
|
||||||
<MoneyInput
|
<MoneyInput
|
||||||
id="RateProbation"
|
id="RateProbation"
|
||||||
value={data.RateProbation}
|
value={data.RateProbation}
|
||||||
onChange={(v) => set("RateProbation", v)}
|
onChange={(v) => set("RateProbation", v)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="5.2 Ставка после ИС" htmlFor="RateAfterProbation">
|
<FormField label="5.2 Ставка после ИС" htmlFor="RateAfterProbation" required>
|
||||||
<MoneyInput
|
<MoneyInput
|
||||||
id="RateAfterProbation"
|
id="RateAfterProbation"
|
||||||
value={data.RateAfterProbation}
|
value={data.RateAfterProbation}
|
||||||
@@ -46,7 +46,7 @@ export const ConditionsSection: React.FC<ISectionProps> = ({
|
|||||||
onLabel="Да"
|
onLabel="Да"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="5.4 График работы" htmlFor="WorkSchedule">
|
<FormField label="5.4 График работы" htmlFor="WorkSchedule" required>
|
||||||
<Select
|
<Select
|
||||||
id="WorkSchedule"
|
id="WorkSchedule"
|
||||||
value={data.WorkSchedule}
|
value={data.WorkSchedule}
|
||||||
@@ -67,7 +67,7 @@ export const ConditionsSection: React.FC<ISectionProps> = ({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
<FormField label="5.5 Время работы" htmlFor="WorkHours">
|
<FormField label="5.5 Время работы" htmlFor="WorkHours" required>
|
||||||
<Select
|
<Select
|
||||||
id="WorkHours"
|
id="WorkHours"
|
||||||
value={data.WorkHours}
|
value={data.WorkHours}
|
||||||
@@ -95,7 +95,7 @@ export const ConditionsSection: React.FC<ISectionProps> = ({
|
|||||||
onLabel="Да"
|
onLabel="Да"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="5.7 Обеспечение рабочего места" full>
|
<FormField label="5.7 Обеспечение рабочего места" required full>
|
||||||
<MultiChoiceField
|
<MultiChoiceField
|
||||||
value={data.WorkplaceProvision}
|
value={data.WorkplaceProvision}
|
||||||
onChange={(v) => set("WorkplaceProvision", v)}
|
onChange={(v) => set("WorkplaceProvision", v)}
|
||||||
@@ -111,7 +111,7 @@ export const ConditionsSection: React.FC<ISectionProps> = ({
|
|||||||
onLabel="Да"
|
onLabel="Да"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="5.9 Предполагаемый наставник">
|
<FormField label="5.9 Предполагаемый наставник" required>
|
||||||
<PeopleField
|
<PeopleField
|
||||||
context={context}
|
context={context}
|
||||||
value={data.ProposedMentor}
|
value={data.ProposedMentor}
|
||||||
@@ -119,7 +119,7 @@ export const ConditionsSection: React.FC<ISectionProps> = ({
|
|||||||
placeholder={PERSON_PLACEHOLDER}
|
placeholder={PERSON_PLACEHOLDER}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="5.10 Непосредственный руководитель">
|
<FormField label="5.10 Непосредственный руководитель" required>
|
||||||
<PeopleField
|
<PeopleField
|
||||||
context={context}
|
context={context}
|
||||||
value={data.DirectManager}
|
value={data.DirectManager}
|
||||||
@@ -127,7 +127,7 @@ export const ConditionsSection: React.FC<ISectionProps> = ({
|
|||||||
placeholder={PERSON_PLACEHOLDER}
|
placeholder={PERSON_PLACEHOLDER}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="5.11 Кто принимает решение о приеме (собеседования)" full>
|
<FormField label="5.11 Кто принимает решение о приеме (собеседования)" required full>
|
||||||
<PeopleFieldMulti
|
<PeopleFieldMulti
|
||||||
context={context}
|
context={context}
|
||||||
value={data.HiringDecisionMaker}
|
value={data.HiringDecisionMaker}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ISectionProps } from '../model';
|
|||||||
|
|
||||||
export const DepartmentSection: React.FC<ISectionProps> = ({ data, set, context }) => (
|
export const DepartmentSection: React.FC<ISectionProps> = ({ data, set, context }) => (
|
||||||
<CollapseSection title="2. Отдел / Подразделение" icon="building">
|
<CollapseSection title="2. Отдел / Подразделение" icon="building">
|
||||||
<FormField label="2.1 Подразделение" htmlFor="Department" full>
|
<FormField label="2.1 Подразделение" htmlFor="Department" required full>
|
||||||
<DepartmentPicker
|
<DepartmentPicker
|
||||||
id="Department"
|
id="Department"
|
||||||
context={context}
|
context={context}
|
||||||
@@ -16,7 +16,7 @@ export const DepartmentSection: React.FC<ISectionProps> = ({ data, set, context
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="2.2 Количество открытых вакансий" htmlFor="OpenVacanciesCount">
|
<FormField label="2.2 Количество открытых вакансий" htmlFor="OpenVacanciesCount" required>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="OpenVacanciesCount"
|
id="OpenVacanciesCount"
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const VacancySection: React.FC<ISectionProps> = ({ data, set, context })
|
|||||||
placeholder="Начните вводить название должности…"
|
placeholder="Начните вводить название должности…"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="3.2 Характер занятости" htmlFor="EmploymentType">
|
<FormField label="3.2 Характер занятости" htmlFor="EmploymentType" required>
|
||||||
<Select
|
<Select
|
||||||
id="EmploymentType"
|
id="EmploymentType"
|
||||||
value={data.EmploymentType}
|
value={data.EmploymentType}
|
||||||
@@ -33,16 +33,16 @@ export const VacancySection: React.FC<ISectionProps> = ({ data, set, context })
|
|||||||
placeholder="— выберите —"
|
placeholder="— выберите —"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="3.3 Функционал" htmlFor="JobFunction" full>
|
<FormField label="3.3 Функционал" htmlFor="JobFunction" required full>
|
||||||
<TextArea id="JobFunction" value={data.JobFunction} onChange={(v) => set('JobFunction', v)} />
|
<TextArea id="JobFunction" value={data.JobFunction} onChange={(v) => set('JobFunction', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="3.4 Компетенции" htmlFor="Competencies" full>
|
<FormField label="3.4 Компетенции" htmlFor="Competencies" required full>
|
||||||
<TextArea id="Competencies" value={data.Competencies} onChange={(v) => set('Competencies', v)} />
|
<TextArea id="Competencies" value={data.Competencies} onChange={(v) => set('Competencies', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="3.5 Личностные характеристики" htmlFor="PersonalTraits" full>
|
<FormField label="3.5 Личностные характеристики" htmlFor="PersonalTraits" required full>
|
||||||
<TextArea id="PersonalTraits" value={data.PersonalTraits} onChange={(v) => set('PersonalTraits', v)} />
|
<TextArea id="PersonalTraits" value={data.PersonalTraits} onChange={(v) => set('PersonalTraits', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="3.6 Образование" htmlFor="Education">
|
<FormField label="3.6 Образование" htmlFor="Education" required>
|
||||||
<Select
|
<Select
|
||||||
id="Education"
|
id="Education"
|
||||||
value={data.Education}
|
value={data.Education}
|
||||||
@@ -51,7 +51,7 @@ export const VacancySection: React.FC<ISectionProps> = ({ data, set, context })
|
|||||||
placeholder="— выберите —"
|
placeholder="— выберите —"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="3.7 Опыт работы" htmlFor="WorkExperience">
|
<FormField label="3.7 Опыт работы" htmlFor="WorkExperience" required>
|
||||||
<Select
|
<Select
|
||||||
id="WorkExperience"
|
id="WorkExperience"
|
||||||
value={data.WorkExperience}
|
value={data.WorkExperience}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { IFormData } from './model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поля формы, обязательные для ОТПРАВКИ на согласование (черновик по-прежнему
|
||||||
|
* можно сохранить не заполнив их). Исключения по требованию заказчика: 3.8
|
||||||
|
* «Необходимые знания в программах» (Programs), 3.9 «Иностранные языки»
|
||||||
|
* (Languages), 3.11 «Водительские права, стаж вождения» (DrivingLicense).
|
||||||
|
* Toggle-поля (BusinessTrips, PieceworkPay, MaterialResponsibility,
|
||||||
|
* ReferralProgram) и условные «другое»-подполя в проверку не входят — у
|
||||||
|
* булевых нет пустого состояния, а «другое» раскрывается только при выборе
|
||||||
|
* соответствующего варианта в уже обязательном родительском поле.
|
||||||
|
*/
|
||||||
|
const REQUIRED_FIELDS: ReadonlyArray<{ label: string; ok: (d: IFormData) => boolean }> = [
|
||||||
|
{ label: '1.3 дата подачи заявки', ok: (d) => !!d.SubmitDate },
|
||||||
|
{ label: '1.4 крайний срок закрытия', ok: (d) => !!d.ClosingDeadline },
|
||||||
|
{ label: '1.5 причина открытия вакансии', ok: (d) => !!d.VacancyOpenReason },
|
||||||
|
{ label: '1.6 организация', ok: (d) => d.Companies.length > 0 },
|
||||||
|
{ label: '2.1 подразделение', ok: (d) => !!d.Department.trim() },
|
||||||
|
{ label: '2.2 количество открытых вакансий', ok: (d) => !!d.OpenVacanciesCount.trim() },
|
||||||
|
{ label: '3.2 характер занятости', ok: (d) => !!d.EmploymentType },
|
||||||
|
{ label: '3.3 функционал', ok: (d) => !!d.JobFunction.trim() },
|
||||||
|
{ label: '3.4 компетенции', ok: (d) => !!d.Competencies.trim() },
|
||||||
|
{ label: '3.5 личностные характеристики', ok: (d) => !!d.PersonalTraits.trim() },
|
||||||
|
{ label: '3.6 образование', ok: (d) => !!d.Education },
|
||||||
|
{ label: '3.7 опыт работы', ok: (d) => !!d.WorkExperience },
|
||||||
|
{ label: '5.1 ставка на ИС', ok: (d) => !!d.RateProbation.trim() },
|
||||||
|
{ label: '5.2 ставка после ИС', ok: (d) => !!d.RateAfterProbation.trim() },
|
||||||
|
{ label: '5.4 график работы', ok: (d) => !!d.WorkSchedule },
|
||||||
|
{ label: '5.5 время работы', ok: (d) => !!d.WorkHours },
|
||||||
|
{ label: '5.7 обеспечение рабочего места', ok: (d) => d.WorkplaceProvision.length > 0 },
|
||||||
|
{ label: '5.9 предполагаемый наставник', ok: (d) => !!d.ProposedMentor },
|
||||||
|
{ label: '5.10 непосредственный руководитель', ok: (d) => !!d.DirectManager },
|
||||||
|
{ label: '5.11 кто принимает решение о приёме', ok: (d) => d.HiringDecisionMaker.length > 0 }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Подписи незаполненных обязательных-для-отправки полей (пусто — всё заполнено). */
|
||||||
|
export function missingRequiredFields(data: IFormData): string[] {
|
||||||
|
return REQUIRED_FIELDS.filter((f) => !f.ok(data)).map((f) => f.label);
|
||||||
|
}
|
||||||
@@ -28,7 +28,9 @@ import {
|
|||||||
unassignedStepMessage,
|
unassignedStepMessage,
|
||||||
resolveWriteListId,
|
resolveWriteListId,
|
||||||
APPROVAL_PHASE,
|
APPROVAL_PHASE,
|
||||||
initialSubmitStatus
|
initialSubmitStatus,
|
||||||
|
resolveApproverOrgInfo,
|
||||||
|
canEditRoute
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import {
|
import {
|
||||||
uploadAttachments,
|
uploadAttachments,
|
||||||
@@ -37,6 +39,7 @@ import {
|
|||||||
IAttachmentInfo
|
IAttachmentInfo
|
||||||
} from '../../shared/attachments/attachments.service';
|
} from '../../shared/attachments/attachments.service';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
import {
|
import {
|
||||||
@@ -82,6 +85,11 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
|
|||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
|
// Право удалять согласующих из маршрута ещё на этапе составления заявки —
|
||||||
|
// см. shared/approval/routeEditRights.ts (сейчас — Алтынай Бултурукова).
|
||||||
|
// Проверяем ТЕКУЩЕГО пользователя портала, а не выбранного «Рекомендателя»
|
||||||
|
// (data.Referrer) — это разные роли, заявку может составлять не сам рекомендатель.
|
||||||
|
const canRemoveApprover = canEditRoute(effectiveUserEmail(context));
|
||||||
|
|
||||||
// editListId уже проверен в EditFeature — если бы партиции не нашлось в реестре,
|
// editListId уже проверен в EditFeature — если бы партиции не нашлось в реестре,
|
||||||
// форма сюда вообще не смонтировалась бы, поэтому referralApprovalService можно
|
// форма сюда вообще не смонтировалась бы, поэтому referralApprovalService можно
|
||||||
@@ -210,7 +218,8 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
|
|||||||
`${data.Referrer}-${editId ?? 'new'}`,
|
`${data.Referrer}-${editId ?? 'new'}`,
|
||||||
!readOnly,
|
!readOnly,
|
||||||
!!editId || (!!data.Referrer && !!requestTypeId),
|
!!editId || (!!data.Referrer && !!requestTypeId),
|
||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login),
|
||||||
|
resolveApproverOrgInfo(context)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Фаза исполнения: реальный исполнитель, назначаемый по маршруту (не
|
// Фаза исполнения: реальный исполнитель, назначаемый по маршруту (не
|
||||||
@@ -231,7 +240,8 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
|
|||||||
`${data.Referrer}-${editId ?? 'new'}`,
|
`${data.Referrer}-${editId ?? 'new'}`,
|
||||||
false,
|
false,
|
||||||
!!editId || (!!data.Referrer && !!requestTypeId),
|
!!editId || (!!data.Referrer && !!requestTypeId),
|
||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login),
|
||||||
|
resolveApproverOrgInfo(context)
|
||||||
);
|
);
|
||||||
|
|
||||||
// «Другое» в «откуда знаете» без уточнения — незаполненное поле по сути.
|
// «Другое» в «откуда знаете» без уточнения — незаполненное поле по сути.
|
||||||
@@ -440,7 +450,11 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
|
|||||||
{/* Блок остаётся видимым и пустым (не скрываем по noApprovalPhase) —
|
{/* Блок остаётся видимым и пустым (не скрываем по noApprovalPhase) —
|
||||||
маршрут без согласующих не значит, что их нельзя добавить: матрица
|
маршрут без согласующих не значит, что их нельзя добавить: матрица
|
||||||
редактируема, заявитель вправе вписать согласующего сам. */}
|
редактируема, заявитель вправе вписать согласующего сам. */}
|
||||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix
|
||||||
|
editor={matrix}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
canRemoveRow={canRemoveApprover ? () => true : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
{!executors.loading && executors.rows.length > 0 && (
|
{!executors.loading && executors.rows.length > 0 && (
|
||||||
<ApprovalMatrix
|
<ApprovalMatrix
|
||||||
|
|||||||
@@ -22,9 +22,14 @@ import {
|
|||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
APPROVAL_PHASE,
|
APPROVAL_PHASE,
|
||||||
isApprovedStatus
|
isApprovedStatus,
|
||||||
|
resolveApproverOrgInfo,
|
||||||
|
canEditRoute,
|
||||||
|
REQUEST_STATUS,
|
||||||
|
IMatrixRow
|
||||||
} 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 { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
import {
|
import {
|
||||||
approvalListTitle,
|
approvalListTitle,
|
||||||
@@ -74,6 +79,10 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
|
|||||||
// Какая из организаций рекомендателя (см. OrganizationField) идёт в печать —
|
// Какая из организаций рекомендателя (см. OrganizationField) идёт в печать —
|
||||||
// выбор в тулбаре через PrintSplitButton, по умолчанию первая.
|
// выбор в тулбаре через PrintSplitButton, по умолчанию первая.
|
||||||
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
|
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
|
||||||
|
// Право удалять согласующих из уже поданного маршрута — см.
|
||||||
|
// shared/approval/routeEditRights.ts (сейчас — Алтынай Бултурукова).
|
||||||
|
const canRemoveApprover = canEditRoute(effectiveUserEmail(context));
|
||||||
|
const [routeError, setRouteError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
|
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
|
||||||
// referralApprovalService можно звать не опасаясь исключения.
|
// referralApprovalService можно звать не опасаясь исключения.
|
||||||
@@ -97,7 +106,7 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
|
|||||||
[approval, itemId]
|
[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), resolveApproverOrgInfo(context)
|
||||||
);
|
);
|
||||||
const loadExecutors = useCallback(
|
const loadExecutors = useCallback(
|
||||||
() =>
|
() =>
|
||||||
@@ -107,9 +116,19 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
|
|||||||
[approval, itemId]
|
[approval, itemId]
|
||||||
);
|
);
|
||||||
const executors = useMatrixEditor(loadExecutors, `e-${itemId}-${decisionSeq}`, false, true, (l) =>
|
const executors = useMatrixEditor(loadExecutors, `e-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||||
approval.getJobTitle(l)
|
approval.getJobTitle(l), resolveApproverOrgInfo(context)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Удалить ещё не решённый шаг маршрута (см. ApproverService.deleteQueuedStep).
|
||||||
|
const handleRemoveApprover = (row: IMatrixRow): void => {
|
||||||
|
if (!row.id) return;
|
||||||
|
setRouteError(undefined);
|
||||||
|
approverService
|
||||||
|
.deleteQueuedStep(itemId, row.id)
|
||||||
|
.then(() => setDecisionSeq((n) => n + 1))
|
||||||
|
.catch((e) => setRouteError(friendlyErrorMessage(e)));
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -199,7 +218,13 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
|
|||||||
</div>
|
</div>
|
||||||
</FormCard>
|
</FormCard>
|
||||||
|
|
||||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
{routeError && <Alert variant="err">{routeError}</Alert>}
|
||||||
|
<ApprovalMatrix
|
||||||
|
editor={approvers}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
canRemoveRow={canRemoveApprover ? (row) => row.status === REQUEST_STATUS.queued : undefined}
|
||||||
|
onRemoveRow={handleRemoveApprover}
|
||||||
|
/>
|
||||||
|
|
||||||
{!executors.loading && executors.rows.length > 0 && (
|
{!executors.loading && executors.rows.length > 0 && (
|
||||||
<ApprovalMatrix
|
<ApprovalMatrix
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ export class ApprovalListService {
|
|||||||
const approver = r[FIELDS.approver] as { Title?: string; EMail?: string } | undefined;
|
const approver = r[FIELDS.approver] as { Title?: string; EMail?: string } | undefined;
|
||||||
const delegatedBy = r[FIELDS.delegatedBy] as { Title?: string } | undefined;
|
const delegatedBy = r[FIELDS.delegatedBy] as { Title?: string } | undefined;
|
||||||
return {
|
return {
|
||||||
|
id: r.Id as number,
|
||||||
order: (r[FIELDS.order] as number) ?? i + 1,
|
order: (r[FIELDS.order] as number) ?? i + 1,
|
||||||
who: approver?.Title || '',
|
who: approver?.Title || '',
|
||||||
email: approver?.EMail || '',
|
email: approver?.EMail || '',
|
||||||
@@ -399,6 +400,14 @@ export class ApprovalListService {
|
|||||||
await execute();
|
await execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Удаляет ОДНУ строку маршрута — см. ApproverService.deleteQueuedStep
|
||||||
|
// (удаление ещё не решённого шага с экрана согласования/составления, см.
|
||||||
|
// routeEditRights.ts). В отличие от deleteForRequest выше, здесь ровно один
|
||||||
|
// элемент — без батча, он тут не даёт выигрыша.
|
||||||
|
public async deleteRow(rowId: number): Promise<void> {
|
||||||
|
await this.list().items.getById(rowId).delete();
|
||||||
|
}
|
||||||
|
|
||||||
// Id текущего пользователя на сайте (для сравнения с полем «Согласующий»).
|
// Id текущего пользователя на сайте (для сравнения с полем «Согласующий»).
|
||||||
// При отладочной подмене личности (см. shared/impersonation.ts) отдаём Id
|
// При отладочной подмене личности (см. shared/impersonation.ts) отдаём Id
|
||||||
// подменённого сотрудника: иначе «моя очередь согласования» и делегирование
|
// подменённого сотрудника: иначе «моя очередь согласования» и делегирование
|
||||||
|
|||||||
@@ -123,6 +123,23 @@ export class ApproverService {
|
|||||||
emitRequestsChanged();
|
emitRequestsChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удалить ЕЩЁ НЕ РЕШЁННЫЙ шаг маршрута (статус «Ждёт очереди») — отдельная от
|
||||||
|
* обычного решения возможность (см. routeEditRights.ts): не «я согласовал/
|
||||||
|
* отклонил свой шаг», а «этого шага в маршруте вообще быть не должно».
|
||||||
|
* Активный («На согласовании») или уже решённый шаг не трогаем — это уже
|
||||||
|
* история решений, удалять её нельзя даже тем, у кого есть это право.
|
||||||
|
*/
|
||||||
|
public async deleteQueuedStep(requestId: number, rowId: number): Promise<void> {
|
||||||
|
const rows = await this.approval.getForRequest(requestId);
|
||||||
|
const row = rows.filter((r) => r.id === rowId)[0];
|
||||||
|
if (!row || row.status !== REQUEST_STATUS.queued) {
|
||||||
|
throw new Error('Шаг уже в работе или решён — удалить нельзя.');
|
||||||
|
}
|
||||||
|
await this.approval.deleteRow(rowId);
|
||||||
|
emitRequestsChanged();
|
||||||
|
}
|
||||||
|
|
||||||
// Применяет решение: обновляет строку, продвигает очередь (на следующего
|
// Применяет решение: обновляет строку, продвигает очередь (на следующего
|
||||||
// согласующего внутри фазы либо на первого исполнителя, если фаза согласования
|
// согласующего внутри фазы либо на первого исполнителя, если фаза согласования
|
||||||
// только что завершилась целиком) и пересчитывает статус заявки.
|
// только что завершилась целиком) и пересчитывает статус заявки.
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { getEmployeeOrganizations } from '../dictionary';
|
||||||
|
|
||||||
|
/** Отдел + организация согласующего — для колонок «Отдел»/«Организация» матрицы. */
|
||||||
|
export interface IApproverOrgInfo {
|
||||||
|
department: string;
|
||||||
|
organization: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Резолвер для useMatrixEditor: отдел и организация — ОДНИМ источником
|
||||||
|
* (Employee/ByEmail, тот же, что и «авто-Организация» в Vacation/UnpaidLeave/…),
|
||||||
|
* а не двумя разными API (иначе строка матрицы собиралась бы по кускам). Один
|
||||||
|
* сотрудник может числиться в нескольких организациях — берём первую запись,
|
||||||
|
* как и остальные формы с авто-подстановкой (см. shared/dictionary).
|
||||||
|
*
|
||||||
|
* email не найден в кадровом справочнике (404) или пуст — getEmployeeOrganizations
|
||||||
|
* отдаёт [], здесь это просто пустые отдел/организация, не ошибка матрицы.
|
||||||
|
*/
|
||||||
|
export function resolveApproverOrgInfo(context: WebPartContext) {
|
||||||
|
return async (email: string): Promise<IApproverOrgInfo> => {
|
||||||
|
const records = await getEmployeeOrganizations(context, email);
|
||||||
|
const first = records[0];
|
||||||
|
return { department: first?.section || '', organization: first?.company || '' };
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -60,6 +60,9 @@ export { notifyActiveStep } from './notify.service';
|
|||||||
export type { INotifyTarget } from './notify.service';
|
export type { INotifyTarget } from './notify.service';
|
||||||
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
|
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
|
||||||
export type { IUseMatrixEditor } from './useMatrixEditor';
|
export type { IUseMatrixEditor } from './useMatrixEditor';
|
||||||
|
export { resolveApproverOrgInfo } from './approverOrgInfo';
|
||||||
|
export type { IApproverOrgInfo } from './approverOrgInfo';
|
||||||
|
export { canEditRoute } from './routeEditRights';
|
||||||
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
||||||
export { UserPicker } from './ui/UserPicker';
|
export { UserPicker } from './ui/UserPicker';
|
||||||
export { DecisionPanel } from './ui/DecisionPanel';
|
export { DecisionPanel } from './ui/DecisionPanel';
|
||||||
|
|||||||
@@ -56,4 +56,21 @@ export interface IMatrixRow {
|
|||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда (IResolvedRouteStep.warning)
|
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда (IResolvedRouteStep.warning)
|
||||||
warning?: string | null;
|
warning?: string | null;
|
||||||
|
/**
|
||||||
|
* Отдел/организация согласующего (см. resolveApproverOrgInfo) — подтягиваются
|
||||||
|
* ОДНИМ источником (Employee/ByEmail) вместе с самой матрицей, а не отдельным
|
||||||
|
* запросом на каждую строку в компоненте отображения. Не заданы — резолвер не
|
||||||
|
* передан useMatrixEditor (форма ещё не подключила колонки) или у сотрудника
|
||||||
|
* нет карточки в кадровом справочнике.
|
||||||
|
*/
|
||||||
|
department?: string;
|
||||||
|
organization?: string;
|
||||||
|
/**
|
||||||
|
* Id элемента списка согласования (SharePoint) для ЭТОЙ строки — нужен, чтобы
|
||||||
|
* удалить конкретную ещё не решённую строку с сервера (см.
|
||||||
|
* ApproverService.deleteQueuedStep/routeEditRights.ts). Не задан у строк
|
||||||
|
* ПЛАНИРУЕМОГО маршрута (getPlannedMatrix — их ещё не существует в SharePoint)
|
||||||
|
* и у только что добавленных в редакторе (появится после сохранения формы).
|
||||||
|
*/
|
||||||
|
id?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Кто может удалять согласующих из маршрута (и при составлении заявки, и на
|
||||||
|
* своём шаге согласования) — сверх обычных прав инициатора/согласующего.
|
||||||
|
*
|
||||||
|
* Пока фиксированный email одного человека (Алтынай Бултурукова, начальник
|
||||||
|
* отдела подбора и адаптации персонала) — группы SharePoint для этого ещё
|
||||||
|
* нет, заведут отдельно (см. [[request-portal-approver-field-rights]] в
|
||||||
|
* памяти). ВРЕМЕННО, как и reportsAccess.ts.
|
||||||
|
*
|
||||||
|
* Подключено только в 3 формах рекрутинга (Заявка на подбор, Заявка на прием,
|
||||||
|
* Реферальная рекомендация) — каждая форма сама решает, передавать ли
|
||||||
|
* canRemoveRow в ApprovalMatrix; здесь только сама проверка личности.
|
||||||
|
*/
|
||||||
|
const ROUTE_EDITOR_EMAILS: ReadonlySet<string> = new Set([
|
||||||
|
'a.bulturukova@tnsu.kz' // Алтынай Бултурукова
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Может ли этот email удалять согласующих из маршрута (см. описание выше). */
|
||||||
|
export function canEditRoute(email: string | undefined): boolean {
|
||||||
|
return !!email && ROUTE_EDITOR_EMAILS.has(email.trim().toLowerCase());
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import styles from './approval.module.scss';
|
|||||||
import { Icon } from '../../ui';
|
import { Icon } from '../../ui';
|
||||||
import { IUseMatrixEditor, matrixRowKeys } from '../useMatrixEditor';
|
import { IUseMatrixEditor, matrixRowKeys } from '../useMatrixEditor';
|
||||||
import { ISiteUser } from '../models/ISiteUser';
|
import { ISiteUser } from '../models/ISiteUser';
|
||||||
|
import { IMatrixRow } from '../models/IMatrixRow';
|
||||||
import { StatusBadge } from './StatusBadge';
|
import { StatusBadge } from './StatusBadge';
|
||||||
import { UserPicker } from './UserPicker';
|
import { UserPicker } from './UserPicker';
|
||||||
import { useFlipRows } from './useFlipRows';
|
import { useFlipRows } from './useFlipRows';
|
||||||
@@ -27,6 +28,22 @@ export interface IApprovalMatrixProps {
|
|||||||
waiting?: boolean;
|
waiting?: boolean;
|
||||||
/** Текст заглушки в режиме waiting. */
|
/** Текст заглушки в режиме waiting. */
|
||||||
waitingText?: string;
|
waitingText?: string;
|
||||||
|
/**
|
||||||
|
* Может ли ТЕКУЩИЙ пользователь удалить именно ЭТУ строку — НЕЗАВИСИМО от
|
||||||
|
* editable (см. shared/approval/routeEditRights.ts: право удалять
|
||||||
|
* согласующих есть у отдельных людей даже вне режима правки черновика,
|
||||||
|
* напр. на экране согласования). Не задан — кнопки удаления нет вообще
|
||||||
|
* (как было по умолчанию).
|
||||||
|
*/
|
||||||
|
canRemoveRow?: (row: IMatrixRow, index: number) => boolean;
|
||||||
|
/**
|
||||||
|
* Что делать по кнопке «Удалить», когда canRemoveRow разрешил. Не задан —
|
||||||
|
* поведение по умолчанию: editor.remove(index) (чисто локально — годится,
|
||||||
|
* когда строку в любом случае перезапишет сохранение формы). Строку УЖЕ
|
||||||
|
* поданной заявки так не удалить — там нужна своя реализация с записью на
|
||||||
|
* сервер (см. RecruitingHireView.tsx и ApproverService.deleteQueuedStep).
|
||||||
|
*/
|
||||||
|
onRemoveRow?: (row: IMatrixRow, index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Таблица маршрута (согласование или исполнение) + (если editable) правка состава. */
|
/** Таблица маршрута (согласование или исполнение) + (если editable) правка состава. */
|
||||||
@@ -39,6 +56,8 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
pickerPlaceholder = 'Добавить согласующего: начните вводить ФИО…',
|
pickerPlaceholder = 'Добавить согласующего: начните вводить ФИО…',
|
||||||
waiting = false,
|
waiting = false,
|
||||||
waitingText = '',
|
waitingText = '',
|
||||||
|
canRemoveRow,
|
||||||
|
onRemoveRow,
|
||||||
}) => {
|
}) => {
|
||||||
const { rows, loading, error, editable, blocks, retry } = editor;
|
const { rows, loading, error, editable, blocks, retry } = editor;
|
||||||
|
|
||||||
@@ -82,6 +101,16 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
editor.toggleParallel(rowIndex);
|
editor.toggleParallel(rowIndex);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Удаление строки, на которую есть право (canRemoveRow) — независимо от
|
||||||
|
// editable. onRemoveRow не задан (обычный редактируемый черновик) —
|
||||||
|
// достаточно локально убрать строку, сохранение формы перезапишет маршрут
|
||||||
|
// целиком; задан (см. RecruitingHireView.tsx) — там своя запись на сервер.
|
||||||
|
const removeRow = (row: IMatrixRow, rowIndex: number): void => {
|
||||||
|
setActiveKeys([]);
|
||||||
|
if (onRemoveRow) onRemoveRow(row, rowIndex);
|
||||||
|
else editor.remove(rowIndex);
|
||||||
|
};
|
||||||
|
|
||||||
const addApprover = (user: ISiteUser): void => {
|
const addApprover = (user: ISiteUser): void => {
|
||||||
setActiveKeys([]);
|
setActiveKeys([]);
|
||||||
editor.addApprover(user);
|
editor.addApprover(user);
|
||||||
@@ -96,6 +125,13 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
// отправке (см. writeRoute). Здесь его достаточно просто показать, повторно
|
// отправке (см. writeRoute). Здесь его достаточно просто показать, повторно
|
||||||
// резолвить нечего.
|
// резолвить нечего.
|
||||||
const hasSla = rows.some((r) => r.slaHours !== undefined && r.slaHours !== null);
|
const hasSla = rows.some((r) => r.slaHours !== undefined && r.slaHours !== null);
|
||||||
|
// Отдел/организация — колонки появляются только у форм, передавших резолвер
|
||||||
|
// в useMatrixEditor (см. resolveApproverOrgInfo); остальные формы их не видят.
|
||||||
|
const hasDepartment = rows.some((r) => !!r.department);
|
||||||
|
const hasOrganization = rows.some((r) => !!r.organization);
|
||||||
|
// Колонка действий с кнопкой удаления нужна даже вне editable, если право
|
||||||
|
// удалять есть хотя бы у одной строки (см. canRemoveRow/routeEditRights.ts).
|
||||||
|
const hasRemovable = rows.some((r, i) => !!canRemoveRow?.(r, i));
|
||||||
|
|
||||||
// Число колонок (для colSpan) — на случай будущих нужд не используется: комментарий
|
// Число колонок (для colSpan) — на случай будущих нужд не используется: комментарий
|
||||||
// выводим внутри ячейки согласующего, чтобы не ломать rowSpan колонки «Шаг».
|
// выводим внутри ячейки согласующего, чтобы не ломать rowSpan колонки «Шаг».
|
||||||
@@ -124,11 +160,13 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
<th className={styles.matrixStepCol}>Шаг</th>
|
<th className={styles.matrixStepCol}>Шаг</th>
|
||||||
<th>{roleLabel}</th>
|
<th>{roleLabel}</th>
|
||||||
<th>Должность</th>
|
<th>Должность</th>
|
||||||
|
{hasDepartment && <th>Отдел</th>}
|
||||||
|
{hasOrganization && <th>Организация</th>}
|
||||||
{hasStatuses && <th>Статус</th>}
|
{hasStatuses && <th>Статус</th>}
|
||||||
{hasDates && <th>Дата</th>}
|
{hasDates && <th>Дата</th>}
|
||||||
{hasSla && <th>SLA</th>}
|
{hasSla && <th>SLA</th>}
|
||||||
{editable && <th />}
|
{editable && <th />}
|
||||||
{editable && <th />}
|
{(editable || hasRemovable) && <th />}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -178,6 +216,8 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Должность">{r.position}</td>
|
<td data-label="Должность">{r.position}</td>
|
||||||
|
{hasDepartment && <td data-label="Отдел">{r.department || '—'}</td>}
|
||||||
|
{hasOrganization && <td data-label="Организация">{r.organization || '—'}</td>}
|
||||||
{hasStatuses && (
|
{hasStatuses && (
|
||||||
<td data-label="Статус">
|
<td data-label="Статус">
|
||||||
{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}
|
{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}
|
||||||
@@ -216,8 +256,9 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
{editable && (
|
{(editable || hasRemovable) && (
|
||||||
<td className={styles.matrixActions}>
|
<td className={styles.matrixActions}>
|
||||||
|
{editable && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${styles.iconBtn} ${r.parallelWithPrev ? styles.iconBtnActive : ''}`}
|
className={`${styles.iconBtn} ${r.parallelWithPrev ? styles.iconBtnActive : ''}`}
|
||||||
@@ -231,6 +272,17 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
>
|
>
|
||||||
<Icon name="user-check" size="sm" />
|
<Icon name="user-check" size="sm" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{canRemoveRow?.(r, i) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
|
||||||
|
onClick={() => removeRow(r, i)}
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
<Icon name="trash" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { friendlyErrorMessage } from '../diagnostics';
|
import { friendlyErrorMessage } from '../diagnostics';
|
||||||
import { IMatrixRow } from './models/IMatrixRow';
|
import { IMatrixRow } from './models/IMatrixRow';
|
||||||
import { ISiteUser } from './models/ISiteUser';
|
import { ISiteUser } from './models/ISiteUser';
|
||||||
|
import { IApproverOrgInfo } from './approverOrgInfo';
|
||||||
|
|
||||||
export interface IUseMatrixEditor {
|
export interface IUseMatrixEditor {
|
||||||
rows: IMatrixRow[];
|
rows: IMatrixRow[];
|
||||||
@@ -147,13 +148,18 @@ export function unassignedStepMessage(
|
|||||||
* @param editable доступны ли правки (порядок/добавление/удаление).
|
* @param editable доступны ли правки (порядок/добавление/удаление).
|
||||||
* @param ready можно ли грузить (false — ждём готовности данных).
|
* @param ready можно ли грузить (false — ждём готовности данных).
|
||||||
* @param getJobTitle должность добавляемого согласующего из профиля.
|
* @param getJobTitle должность добавляемого согласующего из профиля.
|
||||||
|
* @param resolveOrgInfo необязательный резолвер отдела/организации (см.
|
||||||
|
* approverOrgInfo.ts) — колонки «Отдел»/«Организация» в ApprovalMatrix
|
||||||
|
* появляются, только если он передан. Не передан — поведение как раньше,
|
||||||
|
* ни одного лишнего запроса.
|
||||||
*/
|
*/
|
||||||
export function useMatrixEditor(
|
export function useMatrixEditor(
|
||||||
load: () => Promise<IMatrixRow[]>,
|
load: () => Promise<IMatrixRow[]>,
|
||||||
reloadKey: string,
|
reloadKey: string,
|
||||||
editable: boolean,
|
editable: boolean,
|
||||||
ready: boolean,
|
ready: boolean,
|
||||||
getJobTitle: (loginName: string) => Promise<string>
|
getJobTitle: (loginName: string) => Promise<string>,
|
||||||
|
resolveOrgInfo?: (email: string) => Promise<IApproverOrgInfo>
|
||||||
): IUseMatrixEditor {
|
): IUseMatrixEditor {
|
||||||
const [rows, setRows] = useState<IMatrixRow[]>([]);
|
const [rows, setRows] = useState<IMatrixRow[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
@@ -163,11 +169,35 @@ export function useMatrixEditor(
|
|||||||
// повторных попытках, это внутренняя механика самого хука.
|
// повторных попытках, это внутренняя механика самого хука.
|
||||||
const [retrySeq, setRetrySeq] = useState<number>(0);
|
const [retrySeq, setRetrySeq] = useState<number>(0);
|
||||||
|
|
||||||
|
// Отдел/организация — ОДНИМ проходом по уникальным email СРАЗУ после загрузки
|
||||||
|
// строк, а не по мере рендера каждой строки в ApprovalMatrix (иначе те же
|
||||||
|
// email запрашивались бы повторно у параллельных шагов с одним согласующим).
|
||||||
|
// Сбой резолва одного email не должен уронить всю матрицу — просто у той
|
||||||
|
// строки останутся пустые отдел/организация (getEmployeeOrganizations и так
|
||||||
|
// не бросает на 404, но сетевой сбой возможен).
|
||||||
|
const enrichWithOrgInfo = async (loaded: IMatrixRow[]): Promise<IMatrixRow[]> => {
|
||||||
|
if (!resolveOrgInfo) return loaded;
|
||||||
|
const emails = Array.from(new Set(loaded.map((r) => r.email).filter(Boolean)));
|
||||||
|
const infoByEmail = new Map<string, IApproverOrgInfo>();
|
||||||
|
await Promise.all(
|
||||||
|
emails.map((email) =>
|
||||||
|
resolveOrgInfo(email)
|
||||||
|
.then((info) => infoByEmail.set(email, info))
|
||||||
|
.catch(() => undefined)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return loaded.map((r) => {
|
||||||
|
const info = r.email ? infoByEmail.get(r.email) : undefined;
|
||||||
|
return info ? { ...r, department: info.department, organization: info.organization } : r;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ready) return;
|
if (!ready) return;
|
||||||
let active = true;
|
let active = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
load()
|
load()
|
||||||
|
.then((r) => enrichWithOrgInfo(r))
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setRows(markParallel(r));
|
setRows(markParallel(r));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import '@pnp/sp/lists';
|
|||||||
import '@pnp/sp/items';
|
import '@pnp/sp/items';
|
||||||
import '@pnp/sp/attachments';
|
import '@pnp/sp/attachments';
|
||||||
import '@pnp/sp/site-users/web';
|
import '@pnp/sp/site-users/web';
|
||||||
|
import '@pnp/sp/site-groups';
|
||||||
import { BrowserFetchWithRetry } from '@pnp/queryable';
|
import { BrowserFetchWithRetry } from '@pnp/queryable';
|
||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { DATA_SITE_URL } from '../config';
|
import { DATA_SITE_URL } from '../config';
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const ALLOWED_EMAILS: ReadonlySet<string> = new Set([
|
|||||||
'trishina.mariya@tnsu.kz', // Мария Тришина
|
'trishina.mariya@tnsu.kz', // Мария Тришина
|
||||||
'gorodetskiy.timur@tnsu.kz', // Тимур Городецкий
|
'gorodetskiy.timur@tnsu.kz', // Тимур Городецкий
|
||||||
'gorbenko.pavel@tnsu.kz', // Павел Горбенко
|
'gorbenko.pavel@tnsu.kz', // Павел Горбенко
|
||||||
|
'sarniyazova.assilya@tnsu.kz', // Асилья Сарниязова
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// TODO: заменить точечный список на членство в SharePoint-группе HR, когда
|
// TODO: заменить точечный список на членство в SharePoint-группе HR, когда
|
||||||
|
|||||||
@@ -1,10 +1,34 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Button, Icon, FormActions } from '@tnsu/ui-kit';
|
import { Button, Icon, FormActions, Alert } from '@tnsu/ui-kit';
|
||||||
import { IRequestSubmit } from '../useRequestSubmit';
|
import { IRequestSubmit } from '../useRequestSubmit';
|
||||||
|
import { isCurrentUserTester } from './testerGroupAccess';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ВРЕМЕННАЯ ЗАМОРОЗКА (включена 2026-09-21 по просьбе Тимура Городецкого):
|
||||||
|
* приём заявок отключён на ВСЕХ формах портала — и черновик, и отправка на
|
||||||
|
* согласование заблокированы независимо от заполненности полей. Единая точка
|
||||||
|
* отключения — здесь, а не по формам, чтобы снять заморозку можно было одной
|
||||||
|
* правкой (вернуть в false) и не забыть ни одну форму.
|
||||||
|
*
|
||||||
|
* Исключение — участники SharePoint-группы «Тестирование» (см.
|
||||||
|
* testerGroupAccess.ts, добавлено 2026-09-22): для них заморозка не действует,
|
||||||
|
* чтобы можно было проверять формы, пока для всех остальных приём закрыт.
|
||||||
|
*
|
||||||
|
* TODO: снять заморозку (вернуть false), когда будет команда — см. память
|
||||||
|
* request-portal-submission-freeze.
|
||||||
|
*/
|
||||||
|
const SUBMISSION_FROZEN = true;
|
||||||
|
|
||||||
export interface ISubmitActionsProps {
|
export interface ISubmitActionsProps {
|
||||||
submit: IRequestSubmit;
|
submit: IRequestSubmit;
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
|
/**
|
||||||
|
* Доп. условие для кнопки «Отправить», когда у отправки более строгие
|
||||||
|
* требования, чем у черновика (например, поля обязательные только при
|
||||||
|
* отправке на согласование). Не задано — отправка гейтится тем же isValid,
|
||||||
|
* что и черновик (поведение по умолчанию для всех остальных форм).
|
||||||
|
*/
|
||||||
|
submitValid?: boolean;
|
||||||
/** Заявка в нередактируемом статусе (На согласовании/…): сохранять нельзя. */
|
/** Заявка в нередактируемом статусе (На согласовании/…): сохранять нельзя. */
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
@@ -21,6 +45,7 @@ export interface ISubmitActionsProps {
|
|||||||
export const SubmitActions: React.FC<ISubmitActionsProps> = ({
|
export const SubmitActions: React.FC<ISubmitActionsProps> = ({
|
||||||
submit,
|
submit,
|
||||||
isValid,
|
isValid,
|
||||||
|
submitValid,
|
||||||
readOnly,
|
readOnly,
|
||||||
onCancel,
|
onCancel,
|
||||||
onSaveDraft,
|
onSaveDraft,
|
||||||
@@ -28,9 +53,30 @@ export const SubmitActions: React.FC<ISubmitActionsProps> = ({
|
|||||||
submitLabel = 'Отправить на согласование',
|
submitLabel = 'Отправить на согласование',
|
||||||
}) => {
|
}) => {
|
||||||
const { saving, kind } = submit;
|
const { saving, kind } = submit;
|
||||||
const locked = saving || readOnly;
|
// false, пока не пришёл ответ о членстве в группе, — безопасный дефолт: не
|
||||||
|
// мигать разблокированными кнопками до подтверждения, что это тестировщик.
|
||||||
|
const [isTester, setIsTester] = React.useState<boolean>(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!SUBMISSION_FROZEN) return;
|
||||||
|
let alive = true;
|
||||||
|
isCurrentUserTester()
|
||||||
|
.then((v) => {
|
||||||
|
if (alive) setIsTester(v);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const frozen = SUBMISSION_FROZEN && !isTester;
|
||||||
|
const locked = saving || readOnly || frozen;
|
||||||
|
const canSubmit = submitValid ?? isValid;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{frozen && !readOnly && <Alert variant="warn">Подача заявок временно недоступна.</Alert>}
|
||||||
<FormActions>
|
<FormActions>
|
||||||
<Button variant="secondary" onClick={onCancel} disabled={saving}>
|
<Button variant="secondary" onClick={onCancel} disabled={saving}>
|
||||||
Отмена
|
Отмена
|
||||||
@@ -39,10 +85,11 @@ export const SubmitActions: React.FC<ISubmitActionsProps> = ({
|
|||||||
<Icon name="document" size="sm" />
|
<Icon name="document" size="sm" />
|
||||||
Сохранить черновик
|
Сохранить черновик
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="accent" onClick={onSubmit} disabled={!isValid || locked}>
|
<Button variant="accent" onClick={onSubmit} disabled={!canSubmit || locked}>
|
||||||
<Icon name="send" size="sm" />
|
<Icon name="send" size="sm" />
|
||||||
{saving && kind === 'submit' ? 'Отправка…' : submitLabel}
|
{saving && kind === 'submit' ? 'Отправка…' : submitLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</FormActions>
|
</FormActions>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { getSP } from '../../pnp/pnpjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SharePoint-группа «Тестирование» на сайте данных (/sites/requests) — её
|
||||||
|
* участники исключены из портальной заморозки подачи (см. SUBMISSION_FROZEN в
|
||||||
|
* SubmitActions.tsx): могут сохранять черновик и отправлять заявки, пока для
|
||||||
|
* всех остальных это заблокировано. Id группы, а не Title — Title можно
|
||||||
|
* переименовать в SharePoint без последствий для кода.
|
||||||
|
*/
|
||||||
|
const TESTER_GROUP_ID = 124;
|
||||||
|
|
||||||
|
// Кешируем на весь сеанс — группа тестировщиков не меняется настолько часто,
|
||||||
|
// чтобы перезапрашивать её на каждый ремонт SubmitActions (переключение вкладок
|
||||||
|
// заявок и т.п.). Ошибку не кешируем — временный сбой не должен «залипнуть».
|
||||||
|
let cached: Promise<boolean> | undefined;
|
||||||
|
|
||||||
|
async function fetchIsTester(): Promise<boolean> {
|
||||||
|
const groups = await getSP().web.currentUser.groups();
|
||||||
|
return groups.some((g) => g.Id === TESTER_GROUP_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Состоит ли ТЕКУЩИЙ пользователь в группе «Тестирование» (см. описание выше). */
|
||||||
|
export function isCurrentUserTester(): Promise<boolean> {
|
||||||
|
if (!cached) {
|
||||||
|
cached = fetchIsTester().catch((e) => {
|
||||||
|
cached = undefined;
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user