Compare commits
13 Commits
975681105b
...
e8b060add3
| Author | SHA1 | Date | |
|---|---|---|---|
| e8b060add3 | |||
| ad1234b84a | |||
| 353a1aa543 | |||
| 7f641b36c6 | |||
| 9cacb6b271 | |||
| 72e330e659 | |||
| ddf313c951 | |||
| c59b0ad208 | |||
| 6aef9a5ba9 | |||
| 4c018154d3 | |||
| 17f9dec39b | |||
| 4eece061d1 | |||
| 9f97a19cab |
@@ -51,6 +51,7 @@
|
||||
| [`hr-skud-explanation`](../src/webparts/requestPortal/forms/SkudExplanation/README.md) | Объяснительная об отсутствии отметок в СКУД | Отдел кадров | да | нет | да | только «на себя»; дни выбираются кликом в календаре «Мой СКУД» (проходы Hikvision текущего пользователя), сохраняются снимком для согласующего; текст записки собирается автоматически, печать — бланк объяснительной | ⏳ код готов, списки/тип/маршрут не заведены |
|
||||
| [`hr-business-trip`](../src/webparts/requestPortal/forms/BusinessTrip/README.md) | Заявка на командировку | Отдел кадров | да | да | да | можно за другого сотрудника; командирующая организация — выбор из организаций сотрудника; маршрут лентой (точки по Казахстану — город из справочника КАТО на русском и казахском + объект из справочника, за рубежом — страна/город/объект вручную; даты не задним числом, поездка ≤ 40 дней; участки с видом транспорта и временем выезда); суточные (≤ 6 МРП КЗ / ≤ 8 за рубежом) и проживание (ночи сами, ≤ 15 000 ₸ за ночь КЗ) — по точкам; комментарий после итога; задание — список задач; смета: статьи с позициями, привязанными к точкам/участкам (суммой или кол-во × цена — всё вручную), итог с долями и по объектам; обязательство по авансовому отчёту — флажок; печать без шапки адресата | ⏳ код готов, списки/тип/маршрут не заведены |
|
||||
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
|
||||
| [`hr-contract-change`](../src/webparts/requestPortal/forms/ContractChange/README.md) | Изменение условий трудового договора | Отдел кадров | да | — | да | сотрудник из `Employee/FullBase` (и без учётки M365), снимок в текстовых колонках; маршрут по сотруднику, без учётки — по заявителю; 10 видов изменения (один, данные под несколько); поля по видам — в работе | ⏳ каркас, списки/тип/маршрут не заведены |
|
||||
| [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ |
|
||||
|
||||
Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Диагностика: печатает строки маршрута одной заявки из списка «— согласование»
|
||||
(StepOrder, статус, фаза, кем переназначено, кто создал/менял) и историю версий
|
||||
строк исполнения — чтобы понять, откуда в маршруте взялся участник и кто
|
||||
перевёл его в «На исполнении». Только чтение, ничего не меняет.
|
||||
|
||||
.EXAMPLE
|
||||
./Inspect-Route.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/requests" `
|
||||
-ClientId "7459c965-5ed4-4673-8140-0ab8faf9db64" -Tenant "tnsukz.onmicrosoft.com" `
|
||||
-Thumbprint "224B64C8E0CAB1E09AF373E92B3AF4AC33C1233C" `
|
||||
-ListTitle "Прием на работу 1 — согласование" -RequestId 68
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$SiteUrl,
|
||||
[Parameter(Mandatory = $true)][string]$ClientId,
|
||||
[Parameter(Mandatory = $true)][string]$Tenant,
|
||||
[Parameter(Mandatory = $true)][string]$Thumbprint,
|
||||
[Parameter(Mandatory = $true)][string]$ListTitle,
|
||||
[Parameter(Mandatory = $true)][int]$RequestId
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $Tenant -Thumbprint $Thumbprint
|
||||
|
||||
$query = "<View><Query><Where><Eq><FieldRef Name='Ticket' LookupId='TRUE'/><Value Type='Lookup'>$RequestId</Value></Eq></Where>" +
|
||||
"<OrderBy><FieldRef Name='StepOrder'/></OrderBy></Query></View>"
|
||||
$items = Get-PnPListItem -List $ListTitle -Query $query
|
||||
|
||||
function UserText($value) {
|
||||
if ($null -eq $value) { return '' }
|
||||
return $value.Email
|
||||
}
|
||||
|
||||
Write-Host "`nМаршрут заявки #$RequestId («$ListTitle»):`n" -ForegroundColor Cyan
|
||||
$items | ForEach-Object {
|
||||
[pscustomobject]@{
|
||||
Id = $_.Id
|
||||
StepOrder = $_['StepOrder']
|
||||
Phase = $_['Phase']
|
||||
Status = $_['ApprovalStatus']
|
||||
Approver = UserText $_['TansuSiteApprover']
|
||||
DelegatedBy = UserText $_['DelegatedBy']
|
||||
Reassign = $_['AllowReassignToOtherApprovers']
|
||||
Created = $_['Created']
|
||||
CreatedBy = UserText $_['Author']
|
||||
Modified = $_['Modified']
|
||||
ModifiedBy = UserText $_['Editor']
|
||||
}
|
||||
} | Format-Table -AutoSize
|
||||
|
||||
# История версий — только строки исполнения: там видно, кто и когда менял статус.
|
||||
$items | Where-Object { $_['Phase'] -eq 'Исполнение' } | ForEach-Object {
|
||||
Write-Host "`nВерсии строки Id=$($_.Id) ($(UserText $_['TansuSiteApprover'])):" -ForegroundColor Yellow
|
||||
Get-PnPProperty -ClientObject $_ -Property Versions | Out-Null
|
||||
$_.Versions | ForEach-Object {
|
||||
Get-PnPProperty -ClientObject $_ -Property CreatedBy | Out-Null
|
||||
[pscustomobject]@{
|
||||
Version = $_.VersionLabel
|
||||
Created = $_.Created
|
||||
By = $_.CreatedBy.Email
|
||||
Status = $_['ApprovalStatus']
|
||||
StepOrder = $_['StepOrder']
|
||||
}
|
||||
} | Format-Table -AutoSize
|
||||
}
|
||||
|
||||
Disconnect-PnPOnline
|
||||
@@ -42,6 +42,14 @@ export const REQUEST_TYPES: IRequestType[] = [
|
||||
icon: 'people',
|
||||
comingSoon: true
|
||||
},
|
||||
// Пока только тестировщикам: каркас без полей по видам; тип и маршрут не заведены в админке approval-route.
|
||||
{
|
||||
id: 'hr-contract-change',
|
||||
categoryId: 'hr',
|
||||
title: 'Изменение условий трудового договора',
|
||||
icon: 'document',
|
||||
comingSoon: true
|
||||
},
|
||||
|
||||
// Склад / Снабжение — формы открыты для ознакомления, но приём заявок ещё не
|
||||
// запущен (маршруты/списки не готовы): кнопки сохранения/отправки в самих
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
StatusBadge,
|
||||
ApprovalHistory,
|
||||
notifyActiveStep,
|
||||
notifyInitiatorOfReturn,
|
||||
useRequestTypeId,
|
||||
usePhasedRoute
|
||||
} from '../../shared/approval';
|
||||
@@ -108,14 +109,17 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
|
||||
);
|
||||
}
|
||||
|
||||
const notify = (): void => {
|
||||
const notify = (newStatus?: string): void => {
|
||||
// Решение уже применено; без requestTypeId просто некого уведомлять — UI не роняем.
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
const target = {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
const sectionProps = { data, set: NOOP_SET, context };
|
||||
@@ -173,7 +177,7 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
|
||||
onDecided={(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
notify(newStatus);
|
||||
}}
|
||||
onDelegated={() => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
notifyInitiatorOfReturn,
|
||||
useRequestTypeId,
|
||||
APPROVAL_PHASE
|
||||
} from '../../shared/approval';
|
||||
@@ -255,16 +256,19 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
printOrgIndex
|
||||
};
|
||||
|
||||
const notify = (): void => {
|
||||
const notify = (newStatus?: string): void => {
|
||||
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
||||
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
||||
// весь UI ради необязательного оповещения.
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
const target = {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -348,7 +352,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
onDecided={(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
notify(newStatus);
|
||||
}}
|
||||
onDelegated={() => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
notifyInitiatorOfReturn,
|
||||
useRequestTypeId,
|
||||
useViewExecutors,
|
||||
approvalPhaseRows
|
||||
@@ -118,16 +119,19 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
|
||||
|
||||
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
|
||||
|
||||
const notify = (): void => {
|
||||
const notify = (newStatus?: string): void => {
|
||||
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
||||
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
||||
// весь UI ради необязательного оповещения.
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
const target = {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -175,7 +179,7 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
|
||||
onDecided={(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
notify(newStatus);
|
||||
}}
|
||||
onDelegated={() => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
// Ссылка «К списку заявок» — как в остальных формах (см. DismissalForm.module.scss).
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
margin-bottom: $sp-4;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $navy-700;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-sm;
|
||||
cursor: pointer;
|
||||
transition: color .18s;
|
||||
|
||||
&:hover {
|
||||
color: $gold-600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import styles from './ContractChangeForm.module.scss';
|
||||
import {
|
||||
Alert,
|
||||
FormCard,
|
||||
InitiatorLine,
|
||||
FormModeProvider,
|
||||
Icon,
|
||||
RequestNumber,
|
||||
SubmitActions,
|
||||
getDraftDeleteHandler,
|
||||
useRequestSubmit
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
composeRemoveRowRule,
|
||||
ApprovalMatrix,
|
||||
ExecutorsMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
loadRoutePhase,
|
||||
isOpenedRouteReady,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
REQUEST_STATUS,
|
||||
isEditableStatus,
|
||||
IApproverToWrite,
|
||||
unassignedStepMessage,
|
||||
resolveWriteListId,
|
||||
executorsToWrite,
|
||||
initialSubmitStatus,
|
||||
useFormExecutors
|
||||
} from '../../shared/approval';
|
||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||
import { IRequestFormProps } from '../../models/types';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import {
|
||||
approvalListTitle,
|
||||
approvalListTitleForListId,
|
||||
dataListTitle,
|
||||
dataListTitleForListId
|
||||
} from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField, REQUIRED_FIELDS_MESSAGE, routeEmail } from './model';
|
||||
import { saveContractChangeRequest, contractChangeApprovalService } from './submit';
|
||||
import { loadContractChangeRequest } from './load';
|
||||
import { useContractEmployee } from './useContractEmployee';
|
||||
import { RequestSection } from './sections/RequestSection';
|
||||
import { ChangeTypeSections } from './byType/ChangeTypeSections';
|
||||
import { missingRequiredAttachments, prepareFilesFor } from './byType/attachmentSlots';
|
||||
import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
|
||||
import { getAttachments } from '../../shared/attachments/attachments.service';
|
||||
import { useStableCallback } from '../../shared/useStableCallback';
|
||||
|
||||
const ContractChangeForm: React.FC<IRequestFormProps> = ({
|
||||
requestType,
|
||||
context,
|
||||
onCancel,
|
||||
onSaved,
|
||||
editId,
|
||||
editListId,
|
||||
onCodeLoaded
|
||||
}) => {
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||
const submit = useRequestSubmit();
|
||||
// Стабильная обёртка над колбэком родителя — иначе заявка перезапрашивалась бы
|
||||
// при каждом рендере родителя (см. DismissalForm).
|
||||
const notifyCode = useStableCallback(onCodeLoaded);
|
||||
const { setError: setSubmitError } = submit;
|
||||
|
||||
const readOnly = !isEditableStatus(requestStatus);
|
||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||
const applicantEmail = effectiveUserEmail(context);
|
||||
|
||||
const approval = useMemo(() => contractChangeApprovalService(editListId), [editListId]);
|
||||
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
||||
const requestTypeId = requestTypeIdState.id;
|
||||
// Вложения (подписанные слоты по ТЗ «Что приложить», см. byType/attachmentSlots).
|
||||
const dataListRef = editListId || dataListTitle(FORM_ID);
|
||||
const attachments = useAttachmentEditor(dataListRef, requestId);
|
||||
const { setItems: setAttachmentItems } = attachments;
|
||||
|
||||
// Открыта СВОЯ заявка на редактирование (черновик/возврат) — подгружаем содержимое, статус и вложения.
|
||||
useEffect(() => {
|
||||
if (!editId) {
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
Promise.all([loadContractChangeRequest(editId, editListId), getAttachments(dataListRef, editId)])
|
||||
.then(([loaded, files]) => {
|
||||
if (!alive) return;
|
||||
setData(loaded.data);
|
||||
setAttachmentItems(files);
|
||||
setRequestStatus(loaded.status);
|
||||
setRequestCode(loaded.code || undefined);
|
||||
if (loaded.code) notifyCode(loaded.code);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) {
|
||||
setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [editId, editListId, dataListRef, notifyCode, setSubmitError, setAttachmentItems]);
|
||||
|
||||
// Сотрудник + его организация (как в командировке); новая заявка — по умолчанию на себя.
|
||||
const employeeControl = useContractEmployee(context, applicantEmail, !editId, data.Employee, (record) =>
|
||||
set('Employee', record)
|
||||
);
|
||||
|
||||
// Маршрут — по сотруднику, а без учётной записи — по заявителю (см. routeEmail).
|
||||
// У заявки на редактировании маршрут читается из уже записанных строк.
|
||||
const routeUserEmail = routeEmail(employeeControl.person, applicantEmail);
|
||||
const loadMatrix = useCallback(
|
||||
() =>
|
||||
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
|
||||
getPlannedMatrix(context, requestTypeId as number, routeUserEmail, approval, 'Approval')
|
||||
),
|
||||
[context, approval, editId, requestStatus, routeUserEmail, requestTypeId]
|
||||
);
|
||||
const matrix = useMatrixEditor(
|
||||
loadMatrix,
|
||||
`${routeUserEmail}-${editId ?? 'new'}`,
|
||||
!readOnly,
|
||||
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!routeUserEmail && !!requestTypeId)),
|
||||
(login) => approval.getJobTitle(login)
|
||||
);
|
||||
|
||||
const executors = useFormExecutors({
|
||||
readOnly,
|
||||
context,
|
||||
approval,
|
||||
requestTypeId,
|
||||
userEmail: routeUserEmail,
|
||||
editId,
|
||||
requestStatus
|
||||
});
|
||||
|
||||
const isValid = !!data.Employee && data.ChangeTypes.length > 0;
|
||||
const validate = (): string | undefined => {
|
||||
if (!isValid) return REQUIRED_FIELDS_MESSAGE;
|
||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
||||
rows.map((r) => ({
|
||||
email: r.email,
|
||||
position: r.position,
|
||||
allowReassign: r.allowReassign,
|
||||
order: r.order,
|
||||
slaHours: r.slaHours,
|
||||
askOverdueReason: r.askOverdueReason,
|
||||
autoApprove: r.autoApprove
|
||||
}));
|
||||
|
||||
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
||||
// validate() уже гарантировал requestTypeId, но TS этого через замыкание не видит.
|
||||
const rtId = requestTypeId;
|
||||
if (!rtId) {
|
||||
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||
}
|
||||
// Новая заявка пишется в ТЕКУЩУЮ партицию — спрашиваем её прямо перед записью.
|
||||
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
||||
const { id, code } = await saveContractChangeRequest(
|
||||
requestId,
|
||||
rtId,
|
||||
data,
|
||||
toApprovers(matrix.rows),
|
||||
executorsToWrite(executors.rows),
|
||||
status,
|
||||
writeListId
|
||||
);
|
||||
// id фиксируем ДО загрузки файлов: если она упадёт, повторное сохранение
|
||||
// обновит эту же заявку, а не создаст вторую.
|
||||
setRequestId(id);
|
||||
setRequestStatus(status);
|
||||
if (code) setRequestCode(code);
|
||||
// Файлы — после записи элемента (нужен его id), с подписью-префиксом слота.
|
||||
try {
|
||||
await attachments.uploadPending(prepareFilesFor(data), {
|
||||
listRef: writeListId || dataListTitle(FORM_ID),
|
||||
itemId: id
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`Заявка сохранена, но файлы не прикрепились: ${friendlyErrorMessage(e)}. Сохраните ещё раз.`);
|
||||
}
|
||||
if (onSaved) onSaved();
|
||||
// Отправка — уведомляем первый активный шаг; списки той партиции, куда записали.
|
||||
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
|
||||
await notifyActiveStep(context, approval, id, {
|
||||
requestTypeId: rtId,
|
||||
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// На отправку маршрут должен быть укомплектован — шаг без человека молча пропал бы при записи.
|
||||
const validateSubmit = (): string | undefined => {
|
||||
const formError = validate();
|
||||
if (formError) return formError;
|
||||
// Обязательные документы — только на отправку: черновик можно сохранить и без файла.
|
||||
const missingFiles = missingRequiredAttachments(data, attachments);
|
||||
if (missingFiles.length > 0) return `Приложите обязательные документы: ${missingFiles.join(', ')}.`;
|
||||
if (matrix.error) {
|
||||
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
||||
}
|
||||
const unassignedError = unassignedStepMessage(
|
||||
matrix.rows,
|
||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||
);
|
||||
if (unassignedError) return unassignedError;
|
||||
return unassignedStepMessage(
|
||||
executors.rows,
|
||||
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||
const handleSubmit = (): Promise<void> =>
|
||||
submit.run(
|
||||
'submit',
|
||||
save(
|
||||
executors.rows.length > 0
|
||||
? initialSubmitStatus(matrix.rows.length, executors.rows.length)
|
||||
: REQUEST_STATUS.onApproval
|
||||
),
|
||||
validateSubmit
|
||||
);
|
||||
|
||||
const handleDelete = getDraftDeleteHandler({
|
||||
formKey: FORM_ID,
|
||||
requestId,
|
||||
requestStatus,
|
||||
listId: editListId,
|
||||
code: requestCode,
|
||||
submit,
|
||||
onDeleted: onCancel
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button type="button" className={styles.back} onClick={onCancel}>
|
||||
<Icon name="arrow-left" size="sm" />
|
||||
<span>К списку заявок</span>
|
||||
</button>
|
||||
|
||||
<FormCard
|
||||
title={requestType.title}
|
||||
subtitle="Категория: Отдел кадров"
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
|
||||
itemId={requestId}
|
||||
/>
|
||||
}
|
||||
headerRight={
|
||||
requestId || requestStatus ? (
|
||||
<>
|
||||
{requestStatus && <StatusBadge status={requestStatus} />}
|
||||
{requestCode && <RequestNumber code={requestCode} />}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{submit.savedKind && !submit.error && (
|
||||
<Alert variant="ok">
|
||||
{submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'}
|
||||
</Alert>
|
||||
)}
|
||||
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||
|
||||
<FormModeProvider readOnly={readOnly}>
|
||||
<RequestSection data={data} set={set} context={context} employeeControl={employeeControl} />
|
||||
<ChangeTypeSections data={data} set={set} context={context} attachments={attachments} />
|
||||
</FormModeProvider>
|
||||
|
||||
<ApprovalMatrix
|
||||
editor={matrix}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
canRemoveRow={composeRemoveRowRule(applicantEmail)}
|
||||
/>
|
||||
|
||||
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
{/* Автору на доработке важно видеть, почему вернули, — история и в форме. */}
|
||||
{editId && (
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory
|
||||
approval={approval}
|
||||
dataListRef={editListId || dataListTitle(FORM_ID)}
|
||||
requestId={editId}
|
||||
reloadKey={requestStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubmitActions
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
isValid={isValid}
|
||||
readOnly={readOnly}
|
||||
onCancel={onCancel}
|
||||
onSaveDraft={handleSaveDraft}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Отправить на согласование"
|
||||
/>
|
||||
</FormCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContractChangeForm;
|
||||
@@ -0,0 +1,9 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
// Заглушка на время загрузки/ошибки — как в остальных просмотрах.
|
||||
.state {
|
||||
padding: $sp-8;
|
||||
color: $gray-500;
|
||||
font-size: $fs-sm;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './ContractChangeView.module.scss';
|
||||
import { Alert, FormCard, InitiatorLine, FormModeProvider } from '../../shared/ui';
|
||||
import {
|
||||
ApproverService,
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
useViewExecutors,
|
||||
ExecutorsMatrix,
|
||||
approvalPhaseRows,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId
|
||||
} from '../../shared/approval';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import {
|
||||
approvalListTitle,
|
||||
approvalListTitleForListId,
|
||||
dataListTitle,
|
||||
dataListTitleForListId
|
||||
} from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { loadContractChangeRequest } from './load';
|
||||
import { contractChangeApprovalService } from './submit';
|
||||
import { RequestSection } from './sections/RequestSection';
|
||||
import { ChangeTypeSections } from './byType/ChangeTypeSections';
|
||||
import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
|
||||
import { getAttachments } from '../../shared/attachments/attachments.service';
|
||||
import { useStableCallback } from '../../shared/useStableCallback';
|
||||
|
||||
interface IContractChangeViewProps {
|
||||
context: WebPartContext;
|
||||
itemId: number;
|
||||
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
|
||||
formKey: string;
|
||||
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
|
||||
listId?: string;
|
||||
/** Уведомление о загруженном уникальном коде заявки — см. IApprovalViewProps.onCodeLoaded. */
|
||||
onCodeLoaded?: (code: string) => void;
|
||||
}
|
||||
|
||||
const VIEW_TITLE = 'Изменение условий трудового договора';
|
||||
|
||||
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
||||
const NOOP_SET: SetField = () => undefined;
|
||||
|
||||
/**
|
||||
* Заявка только для просмотра (режим согласующего): та же секция, что в форме
|
||||
* подачи, под <FormModeProvider readOnly>, + маршрут, история и панель решения.
|
||||
* Печати пока нет — бланк кадров ещё не согласован.
|
||||
*/
|
||||
const ContractChangeView: React.FC<IContractChangeViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||
const notifyCode = useStableCallback(onCodeLoaded);
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const approval = useMemo(() => contractChangeApprovalService(listId), [listId]);
|
||||
const { id: requestTypeId } = useRequestTypeId(context, formKey);
|
||||
// listId (url) для архивной партиции, Title текущей — см. ApproverService.dataListRef.
|
||||
const dataListRef = listId || dataListTitle(FORM_ID);
|
||||
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
||||
// Вложения — только для показа (секции рисуют их под FormModeProvider readOnly).
|
||||
const attachments = useAttachmentEditor(dataListRef, itemId);
|
||||
const { setItems: setAttachmentItems } = attachments;
|
||||
|
||||
// decisionSeq в reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
|
||||
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||
approval.getJobTitle(l)
|
||||
);
|
||||
const executors = useViewExecutors(context, approval, itemId, decisionSeq);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
Promise.all([loadContractChangeRequest(itemId, listId), getAttachments(dataListRef, itemId)])
|
||||
.then(([loaded, files]) => {
|
||||
if (!alive) return;
|
||||
setData(loaded.data);
|
||||
setAttachmentItems(files);
|
||||
setStatus(loaded.status);
|
||||
setCode(loaded.code);
|
||||
notifyCode(loaded.code);
|
||||
setError('');
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive) return;
|
||||
setError(`Не удалось загрузить заявку №${itemId}: ${friendlyErrorMessage(e)}`);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [itemId, listId, dataListRef, notifyCode, setAttachmentItems]);
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.state}>
|
||||
<Alert variant="err">{error}</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const notify = (): void => {
|
||||
// Тип ещё не резолвился — решение уже применилось, просто некого уведомлять;
|
||||
// не роняем UI ради необязательного оповещения.
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
const handleRouteChanged = (): void => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormCard
|
||||
title={VIEW_TITLE}
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
|
||||
itemId={itemId}
|
||||
/>
|
||||
}
|
||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||
>
|
||||
<FormModeProvider readOnly>
|
||||
<RequestSection data={data} set={NOOP_SET} context={context} />
|
||||
<ChangeTypeSections data={data} set={NOOP_SET} context={context} attachments={attachments} />
|
||||
</FormModeProvider>
|
||||
</FormCard>
|
||||
|
||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
{/* История: маршрут пересохраняется патчем (см. writeRoute), прошлые решения — в версиях строк. */}
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
requestId={itemId}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
onDecided={(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
handleRouteChanged();
|
||||
}}
|
||||
onDelegated={handleRouteChanged}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContractChangeView;
|
||||
@@ -0,0 +1,89 @@
|
||||
# Изменение условий трудового договора (hr-contract-change)
|
||||
|
||||
**Назначение:** заявка на изменение условий трудового договора сотрудника (ст. 33 ТК РК) —
|
||||
повышение оклада, надбавка, перевод, смена режима работы и т. д. (10 видов из ТЗ кадров).
|
||||
**Категория:** Отдел кадров
|
||||
**Списки SP:** «Изменение условий трудового договора 1 — данные» / «… 1 — согласование»
|
||||
**RequestTypeId (внешний API маршрутов):** не хардкодится — резолвится в рантайме по
|
||||
`pageUrl` («hr-contract-change») через `useRequestTypeId` (см. «Формы больше не хардкодят
|
||||
RequestTypeId» в `docs/forms.md`). Тип и маршрут в админке approval-route ещё не заведены.
|
||||
|
||||
> **Статус: каркас.** Есть сотрудник и вид изменения; поля под конкретные виды
|
||||
> добавляются по мере разбора ТЗ кадров. Таблица ТЗ (пример, кто инициирует, что
|
||||
> приложить, сроки, маршрут А/Б/В/Г…) — общая, в форму из неё попадает только то,
|
||||
> что явно согласовано. Печатного бланка пока нет.
|
||||
|
||||
## Маршрут
|
||||
- Фазы: согласование (+ исполнение, если его заведут в админке — форма поддерживает).
|
||||
- Маршрут строится по **e-mail сотрудника**, а если его нет (рабочий без учётной
|
||||
записи M365) — по **e-mail заявителя** (`routeEmail` в `model.ts`); на форме под
|
||||
сотрудником тогда пометка об этом.
|
||||
- Матрица редактируема заявителем при новой заявке / черновике / возврате.
|
||||
- От вида изменения маршрут пока не зависит (`fieldValues` не передаются).
|
||||
|
||||
## Особое поведение
|
||||
- **Сотрудник — из `Employee/FullBase`, а не People-picker** (`EmployeeFullBasePicker`,
|
||||
`shared/dictionary`): так находятся и рабочие без учётной записи. Ищется по части
|
||||
Ф.И.О. (от 3 букв), только работающие (`dismissalDate` пустая). Записи FullBase
|
||||
(одна на место работы) собираются в человека по ИИН — в поиске он одной строкой.
|
||||
- **Организация — отдельный список под сотрудником**, как «Командирующая организация»
|
||||
в командировке: места работы человека в виде «Компания — должность — подразделение».
|
||||
Одно место — выбрано само и заблокировано. Условия меняются в ОДНОМ договоре, поэтому
|
||||
в заявку пишется только выбранное место работы. Логика — `useContractEmployee`.
|
||||
⚠️ На 2026-10-28 FullBase отдаёт по человеку одну запись; бэкенд доработают, чтобы
|
||||
возвращались все места работы — тогда у совместителей появится выбор.
|
||||
- **Хранение сотрудника — снимок в текстовых колонках `Employee*`** (Ф.И.О., ИИН,
|
||||
табельный, e-mail, организация, должность, подразделение, id записи), без колонки
|
||||
People. Справочник со временем меняется, а заявка фиксирует данные на момент подачи.
|
||||
- **По умолчанию — на себя** (`useContractEmployee`): Ф.И.О. из `Employee/ByEmail` →
|
||||
поиск в `FullBase` → человек, у которого есть место работы с e-mail текущего
|
||||
пользователя. Совместителю организацию не выбираем молча — выбирает сам.
|
||||
- **Черновик/возврат:** список организаций подтягивается заново (поиск по Ф.И.О.,
|
||||
тот же ИИН); не нашлось — в списке только сохранённая.
|
||||
- **Маршрут строится по человеку** (его e-mail), а не по месту работы — поэтому
|
||||
грузится сразу после выбора сотрудника, ещё до выбора организации.
|
||||
- **Вид изменения** — `ChangeTypes`, MultiChoice в SP, массив в модели. По ТЗ выбор
|
||||
один (`MAX_CHANGE_TYPES`, обычный `Select`); данные уже рассчитаны на несколько,
|
||||
чтобы снять ограничение правкой UI, без миграции. В коде вид — ключ
|
||||
(`ChangeType`), в SP — подпись (`CHANGE_TYPE_LABELS`): подпись = choice колонки,
|
||||
менять только вместе с `provisioning.json`.
|
||||
- **Заголовок элемента** (`Title`): `Изменение условий ТД — {ФИО} — дд.мм.гггг`.
|
||||
|
||||
## Поля по видам
|
||||
Каждый вид — своя секция в `byType/<Вид>/`, подключается строкой в
|
||||
`byType/ChangeTypeSections.tsx`; ключи модели и колонки SP — с префиксом вида.
|
||||
Колонки невыбранного вида при сохранении очищаются (`null`), чтобы в заявке не
|
||||
оставалось скрытых значений после смены вида.
|
||||
|
||||
| Вид | Секция | Поля (все необязательные, без ограничений) |
|
||||
| --- | --- | --- |
|
||||
| Повышение должностного оклада | `SalaryIncrease` | текущий оклад, новый оклад (`MoneyInput`, Number), дата изменения, причина (испытательный срок / расширение функционала / иное), обоснование — при «иное» это и есть текст причины |
|
||||
| Установление надбавки / доплаты | `Allowance` | основание (а–д: надбавка или доплата видно из пункта), дата начала, дата окончания или «бессрочно» (флажок прячет и очищает дату), размер + переключатель «% / ₸» (`UnitSwitch`, по умолчанию %), обоснование — в т.ч. объём доп. работы при совмещении и текст «иное». Письменное согласие работника на совмещение оформляет кадровик после согласования — не в форме. Вложение **«Результаты аттестации»** — только при основании «г», обязательно |
|
||||
| Перевод на другую должность (постоянный) | `PositionTransfer` | новая должность (`PositionPicker`: справочник `Dictionary/Positions` + ручной ввод), дата перевода, причина (карьерный рост / вакантная должность / новый объект, перераспределение функций / иное), обоснование. Работника и работодателя не различаем — причины видны всем, работник пишет текст заявления в обоснование. Вложение **«Должностная инструкция»** — всегда, обязательно |
|
||||
| Остальные 7 | — | ещё не разобраны с кадрами |
|
||||
|
||||
**Вложения по колонке ТЗ «Что приложить»** (решение 2026-10-28): фиксированные
|
||||
подписанные слоты по виду (подпись — что за документ); обязательны там, где ТЗ
|
||||
прямо требует документ (вид 2 «г» — аттестация, вид 3 — ДИ, вид 8 — новая ДИ;
|
||||
вид 9 — текст ИЛИ файл), «можно приложить» — необязательно (вид 7).
|
||||
|
||||
Как устроено: вложения элемента списка данных, подпись — префикс в имени файла
|
||||
(`shared/attachments/attachmentCategories`, тот же механизм, что в «Приёме»), поле
|
||||
слота — `AttachmentSlotField`. Какие слоты видны и обязательны — одна функция
|
||||
`byType/attachmentSlots.ts` (`activeAttachmentSlots`); новый слот = строка там +
|
||||
`AttachmentSlotField` в секции вида. Файлы грузятся после записи элемента; файлы
|
||||
скрытого слота (сменили вид/основание) не грузятся. Обязательность проверяется
|
||||
только при отправке — черновик сохраняется и без файла.
|
||||
|
||||
## Открытые вопросы (проверить на тесте)
|
||||
- Выдаёт ли бэкенд (вебхуки) права на элемент по People-колонке `Employee`? Если да —
|
||||
сотрудник, за которого подали заявку, её не увидит (такой колонки здесь нет).
|
||||
- `Employee/FullBase`: ищет ли `Fio` по подстроке; что фильтрует `Status`.
|
||||
|
||||
## Секции формы
|
||||
RequestSection (сотрудник · организация · вид изменения) · ChangeTypeSections (секция выбранного вида, см. «Поля по видам»)
|
||||
|
||||
## Точки входа
|
||||
- rptype: `hr-contract-change`
|
||||
- Режим согласующего (просмотр): есть (`ContractChangeView`), без печати
|
||||
- «Создать на основе»: нет
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"dataListTitle": "Изменение условий трудового договора — согласование",
|
||||
"columns": [
|
||||
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||
{ "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] },
|
||||
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Изменение условий трудового договора — данные", "lookupShowField": "Title" },
|
||||
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
||||
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
||||
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
|
||||
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
|
||||
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
|
||||
{ "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" },
|
||||
{ "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" },
|
||||
{ "internalName": "AutoApprove", "title": "Автосогласование", "type": "Boolean" },
|
||||
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
|
||||
]
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
@import '../../../../../../styles/design-tokens';
|
||||
|
||||
// Сумма + переключатель «% / ₸» в одну строку: поле тянется, переключатель — по содержимому.
|
||||
.amount {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: $sp-2;
|
||||
}
|
||||
|
||||
.amountInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import * as React from 'react';
|
||||
import { CollapseSection, FormField, MoneyInput, TextInput, TextArea, Select, Checkbox } from '../../../../shared/ui';
|
||||
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
|
||||
import { CHANGE_TYPE_LABELS } from '../../changeTypes';
|
||||
import { ISectionProps } from '../../model';
|
||||
import { ALLOWANCE_REASONS, ATTESTATION_ATTACHMENT, requiresAttestation } from './options';
|
||||
import { UnitSwitch } from './UnitSwitch';
|
||||
import { sanitizePercentInput, amountForUnit } from './amount';
|
||||
import styles from './AllowanceSection.module.scss';
|
||||
|
||||
/**
|
||||
* Поля вида «Установление надбавки / доплаты». Состав согласован с Тимуром по ТЗ
|
||||
* кадров: основание (а–д), срок с/по или бессрочно, размер в % или ₸, обоснование
|
||||
* (в т.ч. объём доп. работы при совмещении). Поля необязательные; вложение
|
||||
* «Результаты аттестации» — только при основании «г» и там обязательно (по ТЗ).
|
||||
*
|
||||
* Поля — прямо в CollapseSection, без FormGrid (тело секции само сетка).
|
||||
*/
|
||||
export const AllowanceSection: React.FC<ISectionProps> = ({ data, set, attachments }) => {
|
||||
const readOnly = useFormReadOnly();
|
||||
|
||||
return (
|
||||
<CollapseSection title={CHANGE_TYPE_LABELS.allowance} icon="calculator">
|
||||
<FormField label="Основание" htmlFor="AllowanceReason" full>
|
||||
<Select
|
||||
id="AllowanceReason"
|
||||
value={data.AllowanceReason}
|
||||
onChange={(v) => set('AllowanceReason', v)}
|
||||
options={ALLOWANCE_REASONS}
|
||||
placeholder="— выберите —"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Дата начала" htmlFor="AllowanceStartDate">
|
||||
<TextInput
|
||||
id="AllowanceStartDate"
|
||||
type="date"
|
||||
value={data.AllowanceStartDate}
|
||||
onChange={(v) => set('AllowanceStartDate', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Дата окончания" htmlFor="AllowanceEndDate">
|
||||
{/* «Бессрочно» прячет дату окончания — и очищает её, чтобы не сохранить скрытое значение. */}
|
||||
{!data.AllowanceIndefinite && (
|
||||
<TextInput
|
||||
id="AllowanceEndDate"
|
||||
type="date"
|
||||
value={data.AllowanceEndDate}
|
||||
onChange={(v) => set('AllowanceEndDate', v)}
|
||||
/>
|
||||
)}
|
||||
<Checkbox
|
||||
checked={data.AllowanceIndefinite}
|
||||
onChange={(checked) => {
|
||||
set('AllowanceIndefinite', checked);
|
||||
if (checked) set('AllowanceEndDate', '');
|
||||
}}
|
||||
label="Бессрочно"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Размер" htmlFor="AllowanceAmount">
|
||||
<div className={styles.amount}>
|
||||
<div className={styles.amountInput}>
|
||||
{/* Проценты бывают дробными (7,5 %), тенге — целые с разделителем тысяч. */}
|
||||
{data.AllowanceUnit === '%' ? (
|
||||
<TextInput
|
||||
id="AllowanceAmount"
|
||||
value={data.AllowanceAmount}
|
||||
onChange={(v) => set('AllowanceAmount', sanitizePercentInput(v))}
|
||||
placeholder="например, 7.5"
|
||||
/>
|
||||
) : (
|
||||
<MoneyInput id="AllowanceAmount" value={data.AllowanceAmount} onChange={(v) => set('AllowanceAmount', v)} />
|
||||
)}
|
||||
</div>
|
||||
<UnitSwitch
|
||||
value={data.AllowanceUnit}
|
||||
onChange={(unit) => {
|
||||
set('AllowanceUnit', unit);
|
||||
set('AllowanceAmount', amountForUnit(data.AllowanceAmount, unit));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="Обоснование" htmlFor="AllowanceJustification" full>
|
||||
<TextArea
|
||||
id="AllowanceJustification"
|
||||
value={data.AllowanceJustification}
|
||||
onChange={(v) => set('AllowanceJustification', v)}
|
||||
/>
|
||||
</FormField>
|
||||
{attachments && requiresAttestation(data.AllowanceReason) && (
|
||||
<AttachmentSlotField
|
||||
label={ATTESTATION_ATTACHMENT}
|
||||
editor={attachments}
|
||||
editable={!readOnly}
|
||||
groupKey={ATTESTATION_ATTACHMENT}
|
||||
category={ATTESTATION_ATTACHMENT}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</CollapseSection>
|
||||
);
|
||||
};
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
@import '../../../../../../styles/design-tokens';
|
||||
|
||||
// Две кнопки впритык, как сегментный контрол; высота — как у полей ввода рядом.
|
||||
.switch {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $r-sm;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.option {
|
||||
min-width: 44px;
|
||||
padding: 0 $sp-3;
|
||||
border: 0;
|
||||
background: $white;
|
||||
font-family: $font-sans;
|
||||
font-size: 13.5px;
|
||||
color: $gray-700;
|
||||
cursor: pointer;
|
||||
transition: .15s;
|
||||
|
||||
& + & {
|
||||
border-left: 1px solid $gray-300;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: $gray-100;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.active,
|
||||
.active:hover:not(:disabled) {
|
||||
background: $navy-700;
|
||||
color: $white;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||
import { AllowanceUnit, ALLOWANCE_UNITS } from './options';
|
||||
import styles from './UnitSwitch.module.scss';
|
||||
|
||||
interface Props {
|
||||
value: AllowanceUnit;
|
||||
onChange: (unit: AllowanceUnit) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключатель единицы размера «% / ₸» рядом с полем суммы. Сегментного
|
||||
* контрола в ui-kit нет; если понадобится ещё где-то — перенести туда.
|
||||
* В режиме чтения кнопки неактивны, выбранная остаётся подсвеченной.
|
||||
*/
|
||||
export const UnitSwitch: React.FC<Props> = ({ value, onChange }) => {
|
||||
const readOnly = useFormReadOnly();
|
||||
|
||||
return (
|
||||
<div className={styles.switch} role="radiogroup" aria-label="Единица размера">
|
||||
{ALLOWANCE_UNITS.map((unit) => (
|
||||
<button
|
||||
key={unit}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={unit === value}
|
||||
className={unit === value ? `${styles.option} ${styles.active}` : styles.option}
|
||||
disabled={readOnly}
|
||||
onClick={() => onChange(unit)}
|
||||
>
|
||||
{unit}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { sanitizePercentInput, amountForUnit } from './amount';
|
||||
|
||||
describe('sanitizePercentInput', () => {
|
||||
it('оставляет целое как есть', () => {
|
||||
expect(sanitizePercentInput('10')).toBe('10');
|
||||
});
|
||||
|
||||
it('запятую превращает в точку', () => {
|
||||
expect(sanitizePercentInput('7,5')).toBe('7.5');
|
||||
});
|
||||
|
||||
it('выбрасывает буквы, пробелы и знак процента', () => {
|
||||
expect(sanitizePercentInput(' 1a2 %')).toBe('12');
|
||||
});
|
||||
|
||||
it('оставляет только первый разделитель и не больше двух знаков дроби', () => {
|
||||
expect(sanitizePercentInput('1.2.345')).toBe('1.23');
|
||||
});
|
||||
|
||||
it('разрешает разделитель в конце — человек ещё вводит дробь', () => {
|
||||
expect(sanitizePercentInput('7,')).toBe('7.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('amountForUnit', () => {
|
||||
it('при переходе в тенге отбрасывает дробную часть', () => {
|
||||
expect(amountForUnit('7.5', '₸')).toBe('7');
|
||||
});
|
||||
|
||||
it('в процентах значение не меняет', () => {
|
||||
expect(amountForUnit('7.5', '%')).toBe('7.5');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { AllowanceUnit } from './options';
|
||||
|
||||
/** Знаков после разделителя у процента (7,25 %). */
|
||||
const PERCENT_FRACTION_DIGITS = 2;
|
||||
|
||||
/**
|
||||
* Ввод процента → «сырое» число строкой с точкой: «7,5» → «7.5». Пропускаем
|
||||
* только цифры и один разделитель (запятая или точка), дробь — до 2 знаков.
|
||||
* Не type=number: у TextInput нет step, и браузер считал бы 7.5 невалидным при шаге 1.
|
||||
*/
|
||||
export function sanitizePercentInput(text: string): string {
|
||||
const normalized = text.replace(/,/g, '.').replace(/[^\d.]/g, '');
|
||||
const [whole, ...rest] = normalized.split('.');
|
||||
if (rest.length === 0) return whole;
|
||||
return `${whole}.${rest.join('').slice(0, PERCENT_FRACTION_DIGITS)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Значение суммы после смены единицы: в тенге сумма целая (MoneyInput),
|
||||
* поэтому дробную часть процента отбрасываем, а не показываем «75» из «7.5».
|
||||
*/
|
||||
export function amountForUnit(amount: string, unit: AllowanceUnit): string {
|
||||
if (unit === '₸') return amount.split('.')[0];
|
||||
return amount;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ISelectOption } from '../../../../shared/ui';
|
||||
|
||||
/**
|
||||
* Основания надбавки/доплаты — по шаблону текста ТЗ кадров (а–д). Что это —
|
||||
* надбавка или доплата — видно из самого пункта, отдельного поля нет. Значения
|
||||
* совпадают с choices колонки AllowanceReason (provisioning.json).
|
||||
*/
|
||||
export const ALLOWANCE_REASON_MENTORING = 'Надбавка: наставничество и обучение новых работников';
|
||||
export const ALLOWANCE_REASON_TRAVEL = 'Надбавка: разъездной характер работы';
|
||||
export const ALLOWANCE_REASON_COMBINING = 'Доплата: совмещение должностей / расширение зоны обслуживания';
|
||||
export const ALLOWANCE_REASON_HAZARDOUS = 'Доплата: работа в тяжёлых, вредных и (или) опасных условиях труда';
|
||||
export const ALLOWANCE_REASON_OTHER = 'Доплата: иное по решению первого руководителя';
|
||||
|
||||
export const ALLOWANCE_REASONS: ISelectOption[] = [
|
||||
ALLOWANCE_REASON_MENTORING,
|
||||
ALLOWANCE_REASON_TRAVEL,
|
||||
ALLOWANCE_REASON_COMBINING,
|
||||
ALLOWANCE_REASON_HAZARDOUS,
|
||||
ALLOWANCE_REASON_OTHER
|
||||
].map((reason) => ({ value: reason, label: reason }));
|
||||
|
||||
/**
|
||||
* Вложение «Результаты аттестации» — по ТЗ прикладывается документ при доплате
|
||||
* за вредные условия (пункт «г»); там же оно обязательно.
|
||||
*/
|
||||
export const ATTESTATION_ATTACHMENT = 'Результаты аттестации';
|
||||
|
||||
export function requiresAttestation(reason: string): boolean {
|
||||
return reason === ALLOWANCE_REASON_HAZARDOUS;
|
||||
}
|
||||
|
||||
/** Единица размера; значения = choices колонки AllowanceUnit. */
|
||||
export type AllowanceUnit = '%' | '₸';
|
||||
export const ALLOWANCE_UNITS: AllowanceUnit[] = ['%', '₸'];
|
||||
/** По умолчанию — проценты: пример в ТЗ «Надбавка 10%». */
|
||||
export const DEFAULT_ALLOWANCE_UNIT: AllowanceUnit = '%';
|
||||
|
||||
/** Строка из SP → единица; неизвестное/пустое — единица по умолчанию. */
|
||||
export function allowanceUnitFromText(text: string): AllowanceUnit {
|
||||
return ALLOWANCE_UNITS.find((unit) => unit === text) ?? DEFAULT_ALLOWANCE_UNIT;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
import { ChangeType } from '../changeTypes';
|
||||
import { ISectionProps } from '../model';
|
||||
import { SalaryIncreaseSection } from './SalaryIncrease/SalaryIncreaseSection';
|
||||
import { AllowanceSection } from './Allowance/AllowanceSection';
|
||||
import { PositionTransferSection } from './PositionTransfer/PositionTransferSection';
|
||||
|
||||
/**
|
||||
* Вид изменения → секция с его полями. Partial: поля видов добавляются по мере
|
||||
* согласования с кадрами; у вида без секции под выбором просто ничего не появится.
|
||||
* Новый вид = папка byType/<Вид>/ + строка здесь.
|
||||
*/
|
||||
const SECTION_BY_TYPE: Partial<Record<ChangeType, React.FC<ISectionProps>>> = {
|
||||
'salary-increase': SalaryIncreaseSection,
|
||||
allowance: AllowanceSection,
|
||||
'position-transfer': PositionTransferSection
|
||||
};
|
||||
|
||||
/** Секции всех выбранных видов, в порядке выбора (сейчас вид один — см. MAX_CHANGE_TYPES). */
|
||||
export const ChangeTypeSections: React.FC<ISectionProps> = (props) => (
|
||||
<>
|
||||
{props.data.ChangeTypes.map((type) => {
|
||||
const Section = SECTION_BY_TYPE[type];
|
||||
return Section ? <Section key={type} {...props} /> : undefined;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import * as React from 'react';
|
||||
import { CollapseSection, FormField, TextInput, TextArea, Select } from '../../../../shared/ui';
|
||||
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||
import { PositionPicker } from '../../../../shared/dictionary';
|
||||
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
|
||||
import { CHANGE_TYPE_LABELS } from '../../changeTypes';
|
||||
import { ISectionProps } from '../../model';
|
||||
import { POSITION_TRANSFER_REASONS, JOB_DESCRIPTION_ATTACHMENT } from './options';
|
||||
|
||||
/**
|
||||
* Поля вида «Перевод на другую должность (постоянный)». Состав согласован с
|
||||
* Тимуром по ТЗ кадров: новая должность (справочник с ручным вводом), дата,
|
||||
* причина (а–г), обоснование и обязательная должностная инструкция. Текущая
|
||||
* должность уже видна в «Организации» — отдельного «было» нет.
|
||||
*
|
||||
* Поля — прямо в CollapseSection, без FormGrid (тело секции само сетка).
|
||||
*/
|
||||
export const PositionTransferSection: React.FC<ISectionProps> = ({ data, set, context, attachments }) => {
|
||||
const readOnly = useFormReadOnly();
|
||||
|
||||
return (
|
||||
<CollapseSection title={CHANGE_TYPE_LABELS['position-transfer']} icon="people">
|
||||
<FormField label="Новая должность" htmlFor="PositionTransferNewPosition">
|
||||
<PositionPicker
|
||||
context={context}
|
||||
id="PositionTransferNewPosition"
|
||||
value={data.PositionTransferNewPosition}
|
||||
onChange={(v) => set('PositionTransferNewPosition', v)}
|
||||
placeholder="Начните вводить название должности…"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Дата перевода" htmlFor="PositionTransferDate">
|
||||
<TextInput
|
||||
id="PositionTransferDate"
|
||||
type="date"
|
||||
value={data.PositionTransferDate}
|
||||
onChange={(v) => set('PositionTransferDate', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Причина" htmlFor="PositionTransferReason" full>
|
||||
<Select
|
||||
id="PositionTransferReason"
|
||||
value={data.PositionTransferReason}
|
||||
onChange={(v) => set('PositionTransferReason', v)}
|
||||
options={POSITION_TRANSFER_REASONS}
|
||||
placeholder="— выберите —"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Обоснование" htmlFor="PositionTransferJustification" full>
|
||||
<TextArea
|
||||
id="PositionTransferJustification"
|
||||
value={data.PositionTransferJustification}
|
||||
onChange={(v) => set('PositionTransferJustification', v)}
|
||||
/>
|
||||
</FormField>
|
||||
{attachments && (
|
||||
<AttachmentSlotField
|
||||
label={JOB_DESCRIPTION_ATTACHMENT}
|
||||
editor={attachments}
|
||||
editable={!readOnly}
|
||||
groupKey={JOB_DESCRIPTION_ATTACHMENT}
|
||||
category={JOB_DESCRIPTION_ATTACHMENT}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</CollapseSection>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ISelectOption } from '../../../../shared/ui';
|
||||
|
||||
/**
|
||||
* Причины перевода на другую должность — по шаблону текста ТЗ кадров (а–г).
|
||||
* Работника и работодателя не различаем (решение 2026-10-28): причины видны
|
||||
* всем, работник при необходимости выбирает «Иное» и пишет текст в обоснование.
|
||||
* Значения совпадают с choices колонки PositionTransferReason (provisioning.json).
|
||||
*/
|
||||
export const POSITION_TRANSFER_REASONS: ISelectOption[] = [
|
||||
'Карьерный рост (успешное выполнение обязанностей, высокие результаты)',
|
||||
'Вакантная должность, соответствующая квалификации работника',
|
||||
'Открытие нового объекта / проекта, перераспределение функций',
|
||||
'Иное'
|
||||
].map((reason) => ({ value: reason, label: reason }));
|
||||
|
||||
/** Вложение «Должностная инструкция» — по ТЗ ПРИКЛАДЫВАЕТСЯ всегда, обязательно. */
|
||||
export const JOB_DESCRIPTION_ATTACHMENT = 'Должностная инструкция';
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import * as React from 'react';
|
||||
import { CollapseSection, FormField, MoneyInput, TextInput, TextArea, Select } from '../../../../shared/ui';
|
||||
import { CHANGE_TYPE_LABELS } from '../../changeTypes';
|
||||
import { ISectionProps } from '../../model';
|
||||
import { SALARY_INCREASE_REASONS } from './reasons';
|
||||
|
||||
/**
|
||||
* Поля вида «Повышение должностного оклада». Состав согласован с Тимуром по ТЗ
|
||||
* кадров: было/стало, дата, причина (а/б/в) и обоснование. Все необязательные,
|
||||
* без ограничений — по решению от 2026-10-28.
|
||||
*
|
||||
* Поля — прямо в CollapseSection, без FormGrid: тело секции само является
|
||||
* двухколоночной сеткой, вложенная FormGrid сжала бы всё в одну её ячейку.
|
||||
*/
|
||||
export const SalaryIncreaseSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||
<CollapseSection title={CHANGE_TYPE_LABELS['salary-increase']} icon="calculator">
|
||||
<FormField label="Текущий оклад, ₸" htmlFor="SalaryIncreaseCurrent">
|
||||
<MoneyInput
|
||||
id="SalaryIncreaseCurrent"
|
||||
value={data.SalaryIncreaseCurrent}
|
||||
onChange={(v) => set('SalaryIncreaseCurrent', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Новый оклад, ₸" htmlFor="SalaryIncreaseNew">
|
||||
<MoneyInput
|
||||
id="SalaryIncreaseNew"
|
||||
value={data.SalaryIncreaseNew}
|
||||
onChange={(v) => set('SalaryIncreaseNew', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Дата изменения" htmlFor="SalaryIncreaseDate">
|
||||
<TextInput
|
||||
id="SalaryIncreaseDate"
|
||||
type="date"
|
||||
value={data.SalaryIncreaseDate}
|
||||
onChange={(v) => set('SalaryIncreaseDate', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Причина" htmlFor="SalaryIncreaseReason">
|
||||
<Select
|
||||
id="SalaryIncreaseReason"
|
||||
value={data.SalaryIncreaseReason}
|
||||
onChange={(v) => set('SalaryIncreaseReason', v)}
|
||||
options={SALARY_INCREASE_REASONS}
|
||||
placeholder="— выберите —"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Обоснование" htmlFor="SalaryIncreaseJustification" full>
|
||||
<TextArea
|
||||
id="SalaryIncreaseJustification"
|
||||
value={data.SalaryIncreaseJustification}
|
||||
onChange={(v) => set('SalaryIncreaseJustification', v)}
|
||||
/>
|
||||
</FormField>
|
||||
</CollapseSection>
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ISelectOption } from '../../../../shared/ui';
|
||||
|
||||
/**
|
||||
* Причины повышения оклада — по шаблону текста ТЗ кадров (а/б/в). Значения
|
||||
* совпадают с choices колонки SalaryIncreaseReason (provisioning.json).
|
||||
* «Иное» отдельного текста не имеет — его раскрывает поле «Обоснование».
|
||||
*/
|
||||
export const SALARY_REASON_PROBATION = 'Успешное прохождение испытательного срока';
|
||||
export const SALARY_REASON_RESPONSIBILITY = 'Расширение функционала / зоны ответственности';
|
||||
export const SALARY_REASON_OTHER = 'Иное';
|
||||
|
||||
export const SALARY_INCREASE_REASONS: ISelectOption[] = [
|
||||
{ value: SALARY_REASON_PROBATION, label: SALARY_REASON_PROBATION },
|
||||
{ value: SALARY_REASON_RESPONSIBILITY, label: SALARY_REASON_RESPONSIBILITY },
|
||||
{ value: SALARY_REASON_OTHER, label: SALARY_REASON_OTHER }
|
||||
];
|
||||
@@ -0,0 +1,48 @@
|
||||
import { PrepareFiles, IAttachmentEditor } from '../../../shared/attachments/useAttachmentEditor';
|
||||
import { attachmentsOfCategory, prefixFiles } from '../../../shared/attachments/attachmentCategories';
|
||||
import { IFormData } from '../model';
|
||||
import { ATTESTATION_ATTACHMENT, requiresAttestation } from './Allowance/options';
|
||||
import { JOB_DESCRIPTION_ATTACHMENT } from './PositionTransfer/options';
|
||||
|
||||
/**
|
||||
* Подписанный слот вложений по колонке ТЗ «Что приложить». Подпись (category)
|
||||
* одновременно и префикс в имени файла, и ключ группы в editor.pending.
|
||||
*/
|
||||
export interface IAttachmentSlot {
|
||||
category: string;
|
||||
/** Обязателен там, где ТЗ прямо требует документ. */
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
/** Слоты, видимые при текущих данных формы. Новый слот вида = строка здесь. */
|
||||
export function activeAttachmentSlots(data: IFormData): IAttachmentSlot[] {
|
||||
const slots: IAttachmentSlot[] = [];
|
||||
if (data.ChangeTypes.includes('allowance') && requiresAttestation(data.AllowanceReason)) {
|
||||
slots.push({ category: ATTESTATION_ATTACHMENT, required: true });
|
||||
}
|
||||
if (data.ChangeTypes.includes('position-transfer')) {
|
||||
slots.push({ category: JOB_DESCRIPTION_ATTACHMENT, required: true });
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Подготовка выбранных файлов к загрузке: префикс-подпись в имени. Файлы слота,
|
||||
* который сейчас скрыт (сменили вид или основание), не загружаем — их не видно.
|
||||
*/
|
||||
export function prepareFilesFor(data: IFormData): PrepareFiles {
|
||||
const visible = activeAttachmentSlots(data).map((slot) => slot.category);
|
||||
return (groupKey, files) => (visible.indexOf(groupKey) >= 0 ? prefixFiles(files, groupKey) : []);
|
||||
}
|
||||
|
||||
/** Подписи обязательных слотов, где нет ни прикреплённого, ни выбранного файла. */
|
||||
export function missingRequiredAttachments(data: IFormData, editor: IAttachmentEditor): string[] {
|
||||
return activeAttachmentSlots(data)
|
||||
.filter((slot) => slot.required)
|
||||
.filter((slot) => {
|
||||
const uploaded = attachmentsOfCategory(editor.items, slot.category).length;
|
||||
const selected = (editor.pending[slot.category] || []).length;
|
||||
return uploaded + selected === 0;
|
||||
})
|
||||
.map((slot) => slot.category);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ISelectOption } from '../../shared/ui';
|
||||
|
||||
/**
|
||||
* Виды изменения условий трудового договора (ст. 33 ТК РК) — по ТЗ кадров.
|
||||
*
|
||||
* В коде вид — стабильный ключ (union ниже), в SharePoint — его подпись: колонка
|
||||
* ChangeTypes (MultiChoice) хранит текст, чтобы кадры видели в списке и отчётах
|
||||
* понятные значения. Подпись = choice колонки, поэтому менять её можно только
|
||||
* вместе с choices в provisioning.json (иначе старые заявки перестанут узнаваться).
|
||||
*/
|
||||
export type ChangeType =
|
||||
| 'salary-increase'
|
||||
| 'allowance'
|
||||
| 'position-transfer'
|
||||
| 'department-transfer'
|
||||
| 'workplace-change'
|
||||
| 'contract-extension'
|
||||
| 'schedule-change'
|
||||
| 'function-change'
|
||||
| 'employer-initiative'
|
||||
| 'temporary-transfer';
|
||||
|
||||
/** Порядок — как в ТЗ (в нём же показываются в выпадающем списке). */
|
||||
export const CHANGE_TYPE_LABELS: Record<ChangeType, string> = {
|
||||
'salary-increase': 'Повышение должностного оклада',
|
||||
allowance: 'Установление надбавки / доплаты',
|
||||
'position-transfer': 'Перевод на другую должность (постоянный)',
|
||||
'department-transfer': 'Перевод в другое подразделение',
|
||||
'workplace-change': 'Изменение места работы (другая местность)',
|
||||
'contract-extension': 'Продление срока трудового договора',
|
||||
'schedule-change':
|
||||
'Изменение режима / графика работы по соглашению сторон; неполное рабочее время по просьбе работника',
|
||||
'function-change': 'Изменение трудовой функции (без смены должности)',
|
||||
// Самая длинная подпись (~225 символов) — близко к лимиту choice в SP (255), не удлинять.
|
||||
'employer-initiative':
|
||||
'Изменение условий труда по инициативе работодателя по причинам экономического, технологического характера, ' +
|
||||
'изменений в организации и (или) сокращения объёма работ (снижение оклада, сокращение рабочего времени, изменение графика)',
|
||||
'temporary-transfer': 'Временный перевод при производственной необходимости / простое'
|
||||
};
|
||||
|
||||
/**
|
||||
* Сколько видов можно выбрать в одной заявке. По ТЗ — один; данные (массив
|
||||
* ChangeTypes + MultiChoice-колонка) уже рассчитаны на несколько, чтобы снять
|
||||
* ограничение правкой UI, а не миграцией.
|
||||
*/
|
||||
export const MAX_CHANGE_TYPES = 1;
|
||||
|
||||
export const CHANGE_TYPE_OPTIONS: ISelectOption[] = (Object.keys(CHANGE_TYPE_LABELS) as ChangeType[]).map(
|
||||
(type) => ({ value: type, label: CHANGE_TYPE_LABELS[type] })
|
||||
);
|
||||
|
||||
/** Подпись из SP → ключ вида. Неизвестная подпись (переименовали choice) — undefined. */
|
||||
export function changeTypeFromLabel(label: string): ChangeType | undefined {
|
||||
const normalized = label.trim();
|
||||
return (Object.keys(CHANGE_TYPE_LABELS) as ChangeType[]).find(
|
||||
(type) => CHANGE_TYPE_LABELS[type] === normalized
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { getSP } from '../../shared/pnp/pnpjs';
|
||||
import { dataListTitle } from '../../shared/listTitles';
|
||||
import { spDateOnlyToIso } from '../../shared/dateUtils';
|
||||
import { IEmployeeFullBaseRecord } from '../../shared/dictionary';
|
||||
import { ChangeType, changeTypeFromLabel } from './changeTypes';
|
||||
import { allowanceUnitFromText } from './byType/Allowance/options';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA } from './model';
|
||||
|
||||
export interface ILoadedRequest {
|
||||
data: IFormData;
|
||||
status: string;
|
||||
/** Уникальный код заявки; '' — если запись создана до появления поля. */
|
||||
code: string;
|
||||
}
|
||||
|
||||
function text(raw: unknown): string {
|
||||
return typeof raw === 'string' ? raw : '';
|
||||
}
|
||||
|
||||
/** Number-колонка → «сырое» число строкой (для MoneyInput / ввода процента); пусто → ''. */
|
||||
function numberText(raw: unknown): string {
|
||||
return typeof raw === 'number' && Number.isFinite(raw) ? String(raw) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Колонки Employee* → запись сотрудника (см. employeeColumns в submit.ts).
|
||||
* Нет ни id, ни Ф.И.О. — сотрудник не был выбран (черновик) → undefined.
|
||||
* Даты приёма/увольнения не хранятся — ''; e-mail пустой — у сотрудника нет учётки.
|
||||
*/
|
||||
function employeeFromItem(item: Record<string, unknown>): IEmployeeFullBaseRecord | undefined {
|
||||
const id = text(item.EmployeeRecordId);
|
||||
const fio = text(item.EmployeeFio);
|
||||
if (!id && !fio) return undefined;
|
||||
return {
|
||||
id,
|
||||
fio,
|
||||
iin: text(item.EmployeeIin),
|
||||
code: text(item.EmployeeCode),
|
||||
company: text(item.EmployeeCompany),
|
||||
position: text(item.EmployeePosition),
|
||||
department: text(item.EmployeeDepartment),
|
||||
admissionDate: '',
|
||||
dismissalDate: '',
|
||||
email: text(item.EmployeeEmail)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MultiChoice → ключи видов. Подпись, которой нет в CHANGE_TYPE_LABELS (choice
|
||||
* переименовали в SP, а код — нет), пропускаем с предупреждением в консоли:
|
||||
* форма покажет «вид не выбран», и при отправке валидация попросит выбрать заново.
|
||||
*/
|
||||
function changeTypesFromItem(raw: unknown): ChangeType[] {
|
||||
const labels = Array.isArray(raw) ? raw.filter((v): v is string => typeof v === 'string') : [];
|
||||
const types: ChangeType[] = [];
|
||||
labels.forEach((label) => {
|
||||
const type = changeTypeFromLabel(label);
|
||||
if (type) {
|
||||
types.push(type);
|
||||
} else {
|
||||
console.warn(`[ContractChange] Неизвестный вид изменения «${label}» — нет в CHANGE_TYPE_LABELS`);
|
||||
}
|
||||
});
|
||||
return types;
|
||||
}
|
||||
|
||||
/**
|
||||
* Прочитать заявку по id: модель формы + статус (правка / просмотр).
|
||||
*
|
||||
* listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/
|
||||
* listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции; задан — идём напрямую
|
||||
* по url списка.
|
||||
*/
|
||||
export async function loadContractChangeRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
|
||||
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||
const item = (await list.items
|
||||
.getById(itemId)
|
||||
.select(
|
||||
'Id',
|
||||
'RequestStatus',
|
||||
'RequestUniqueCode',
|
||||
'EmployeeRecordId',
|
||||
'EmployeeFio',
|
||||
'EmployeeIin',
|
||||
'EmployeeCode',
|
||||
'EmployeeEmail',
|
||||
'EmployeeCompany',
|
||||
'EmployeePosition',
|
||||
'EmployeeDepartment',
|
||||
'ChangeTypes',
|
||||
'SalaryIncreaseCurrent',
|
||||
'SalaryIncreaseNew',
|
||||
'SalaryIncreaseDate',
|
||||
'SalaryIncreaseReason',
|
||||
'SalaryIncreaseJustification',
|
||||
'AllowanceReason',
|
||||
'AllowanceStartDate',
|
||||
'AllowanceEndDate',
|
||||
'AllowanceIndefinite',
|
||||
'AllowanceAmount',
|
||||
'AllowanceUnit',
|
||||
'AllowanceJustification',
|
||||
'PositionTransferNewPosition',
|
||||
'PositionTransferDate',
|
||||
'PositionTransferReason',
|
||||
'PositionTransferJustification'
|
||||
)()) as Record<string, unknown>;
|
||||
|
||||
const data: IFormData = {
|
||||
...EMPTY_DATA,
|
||||
Employee: employeeFromItem(item),
|
||||
ChangeTypes: changeTypesFromItem(item.ChangeTypes),
|
||||
SalaryIncreaseCurrent: numberText(item.SalaryIncreaseCurrent),
|
||||
SalaryIncreaseNew: numberText(item.SalaryIncreaseNew),
|
||||
// Дату только через spDateOnlyToIso — иначе из-за часового пояса уезжает на день назад.
|
||||
SalaryIncreaseDate: spDateOnlyToIso(text(item.SalaryIncreaseDate)),
|
||||
SalaryIncreaseReason: text(item.SalaryIncreaseReason),
|
||||
SalaryIncreaseJustification: text(item.SalaryIncreaseJustification),
|
||||
AllowanceReason: text(item.AllowanceReason),
|
||||
AllowanceStartDate: spDateOnlyToIso(text(item.AllowanceStartDate)),
|
||||
AllowanceEndDate: spDateOnlyToIso(text(item.AllowanceEndDate)),
|
||||
AllowanceIndefinite: item.AllowanceIndefinite === true,
|
||||
AllowanceAmount: numberText(item.AllowanceAmount),
|
||||
AllowanceUnit: allowanceUnitFromText(text(item.AllowanceUnit)),
|
||||
AllowanceJustification: text(item.AllowanceJustification),
|
||||
PositionTransferNewPosition: text(item.PositionTransferNewPosition),
|
||||
PositionTransferDate: spDateOnlyToIso(text(item.PositionTransferDate)),
|
||||
PositionTransferReason: text(item.PositionTransferReason),
|
||||
PositionTransferJustification: text(item.PositionTransferJustification)
|
||||
};
|
||||
|
||||
return {
|
||||
data,
|
||||
status: text(item.RequestStatus),
|
||||
code: text(item.RequestUniqueCode)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { RequestTypeId } from '../../models/types';
|
||||
import { IEmployeeFullBasePerson, IEmployeeFullBaseRecord } from '../../shared/dictionary';
|
||||
import { ChangeType } from './changeTypes';
|
||||
import { AllowanceUnit, DEFAULT_ALLOWANCE_UNIT } from './byType/Allowance/options';
|
||||
import { IAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
|
||||
|
||||
/**
|
||||
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
|
||||
* SharePoint выводятся из него, см. shared/listTitles.ts.
|
||||
*/
|
||||
export const FORM_ID: RequestTypeId = 'hr-contract-change';
|
||||
|
||||
/**
|
||||
* Данные формы «Изменение условий трудового договора» (ст. 33 ТК РК).
|
||||
*
|
||||
* Сотрудник — запись из Employee/FullBase, а не People: заявку подают и за
|
||||
* рабочих без учётной записи M365. В списке SP запись лежит снимком в текстовых
|
||||
* колонках Employee* (разборка/сборка — submit.ts/load.ts); даты приёма и
|
||||
* увольнения не храним — после загрузки они ''.
|
||||
*
|
||||
* Поля под конкретные виды изменения добавляются по мере согласования с кадрами
|
||||
* — каждый вид своим блоком с префиксом имён (см. changeTypes.ts).
|
||||
*/
|
||||
export interface IFormData {
|
||||
/**
|
||||
* Место работы сотрудника, где меняются условия ТД (человек + организация —
|
||||
* см. useContractEmployee). undefined — не выбран человек или ещё не выбрана организация.
|
||||
*/
|
||||
Employee: IEmployeeFullBaseRecord | undefined;
|
||||
/**
|
||||
* Выбранные виды изменения. По ТЗ ровно один (MAX_CHANGE_TYPES), массив —
|
||||
* задел под несколько, см. changeTypes.ts. [] — не выбран.
|
||||
*/
|
||||
ChangeTypes: ChangeType[];
|
||||
|
||||
// ── Вид «Повышение должностного оклада» (byType/SalaryIncrease). Все необязательные.
|
||||
/** Текущий оклад — «сырые» цифры без пробелов (так отдаёт MoneyInput); '' — не заполнен. */
|
||||
SalaryIncreaseCurrent: string;
|
||||
/** Новый оклад — как SalaryIncreaseCurrent. */
|
||||
SalaryIncreaseNew: string;
|
||||
/** С какой даты, ISO YYYY-MM-DD. */
|
||||
SalaryIncreaseDate: string;
|
||||
/** Причина — одно из SALARY_INCREASE_REASONS (Choice-колонка); '' — не выбрана. */
|
||||
SalaryIncreaseReason: string;
|
||||
/** Обоснование; при причине «Иное» — её текст. */
|
||||
SalaryIncreaseJustification: string;
|
||||
|
||||
// ── Вид «Установление надбавки / доплаты» (byType/Allowance). Все необязательные.
|
||||
/** Основание — одно из ALLOWANCE_REASONS (Choice-колонка); '' — не выбрано. */
|
||||
AllowanceReason: string;
|
||||
/** С какой даты, ISO YYYY-MM-DD. */
|
||||
AllowanceStartDate: string;
|
||||
/** По какую дату, ISO; при AllowanceIndefinite всегда ''. */
|
||||
AllowanceEndDate: string;
|
||||
/** Бессрочно — тогда даты окончания нет. */
|
||||
AllowanceIndefinite: boolean;
|
||||
/**
|
||||
* Размер — «сырое» число строкой: в ₸ целое (MoneyInput), в % может быть дробным
|
||||
* с точкой, «7.5» (см. byType/Allowance/amount.ts); единица — в AllowanceUnit.
|
||||
*/
|
||||
AllowanceAmount: string;
|
||||
AllowanceUnit: AllowanceUnit;
|
||||
/** Обоснование; при совмещении — в т.ч. объём доп. работы, при «иное» — его текст. */
|
||||
AllowanceJustification: string;
|
||||
|
||||
// ── Вид «Перевод на другую должность (постоянный)» (byType/PositionTransfer).
|
||||
// Поля необязательные; вложение «Должностная инструкция» — обязательно (attachmentSlots).
|
||||
/** Новая должность — из справочника или вручную (PositionPicker). */
|
||||
PositionTransferNewPosition: string;
|
||||
/** С какой даты, ISO YYYY-MM-DD. */
|
||||
PositionTransferDate: string;
|
||||
/** Причина — одно из POSITION_TRANSFER_REASONS (Choice-колонка); '' — не выбрана. */
|
||||
PositionTransferReason: string;
|
||||
/** Обоснование; при «Иное» — его текст, у работника — текст заявления. */
|
||||
PositionTransferJustification: string;
|
||||
}
|
||||
|
||||
/** Обновление одного поля формы с сохранением типа значения. */
|
||||
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
|
||||
|
||||
/** Пропсы секции формы: срез данных + колбэк обновления + контекст (запросы к справочникам). */
|
||||
export interface ISectionProps {
|
||||
data: IFormData;
|
||||
set: SetField;
|
||||
context: WebPartContext;
|
||||
/** Вложения заявки — для подписанных слотов в секциях видов (см. byType/attachmentSlots). */
|
||||
attachments?: IAttachmentEditor;
|
||||
}
|
||||
|
||||
export const EMPTY_DATA: IFormData = {
|
||||
Employee: undefined,
|
||||
ChangeTypes: [],
|
||||
SalaryIncreaseCurrent: '',
|
||||
SalaryIncreaseNew: '',
|
||||
SalaryIncreaseDate: '',
|
||||
SalaryIncreaseReason: '',
|
||||
SalaryIncreaseJustification: '',
|
||||
AllowanceReason: '',
|
||||
AllowanceStartDate: '',
|
||||
AllowanceEndDate: '',
|
||||
AllowanceIndefinite: false,
|
||||
AllowanceAmount: '',
|
||||
AllowanceUnit: DEFAULT_ALLOWANCE_UNIT,
|
||||
AllowanceJustification: '',
|
||||
PositionTransferNewPosition: '',
|
||||
PositionTransferDate: '',
|
||||
PositionTransferReason: '',
|
||||
PositionTransferJustification: ''
|
||||
};
|
||||
|
||||
/** Плейсхолдер для поля выбора сотрудника. */
|
||||
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
|
||||
/** Плейсхолдер для выбора организации (места работы) сотрудника. */
|
||||
export const WORKPLACE_PLACEHOLDER = 'Выберите организацию, в которой меняются условия';
|
||||
/** Плейсхолдер для выбора вида изменения. */
|
||||
export const CHANGE_TYPE_PLACEHOLDER = '— выберите вид изменения —';
|
||||
/**
|
||||
* Пояснение под сотрудником без учётной записи: approval-route резолвит маршрут
|
||||
* по e-mail, а его нет — маршрут строится по заявителю (см. routeEmail в форме).
|
||||
*/
|
||||
export const ROUTE_BY_APPLICANT_NOTE =
|
||||
'У сотрудника нет учётной записи — маршрут согласования построен по заявителю.';
|
||||
export const REQUIRED_FIELDS_MESSAGE = 'Заполните обязательные поля: сотрудник, организация, вид изменения.';
|
||||
|
||||
/**
|
||||
* E-mail, по которому строится маршрут: сотрудника, а если у него нет учётной
|
||||
* записи (рабочие на объектах) — заявителя. Берётся от человека, а не от места
|
||||
* работы: маршрут от организации не зависит и грузится, пока её ещё выбирают.
|
||||
*/
|
||||
export function routeEmail(person: IEmployeeFullBasePerson | undefined, applicantEmail: string): string {
|
||||
if (!person) return '';
|
||||
return person.email || applicantEmail;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"dataListTitle": "Изменение условий трудового договора — данные",
|
||||
"columns": [
|
||||
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
|
||||
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
||||
|
||||
{ "internalName": "EmployeeRecordId", "title": "Сотрудник: id записи кадровой базы", "type": "Text" },
|
||||
{ "internalName": "EmployeeFio", "title": "Сотрудник: Ф.И.О.", "type": "Text" },
|
||||
{ "internalName": "EmployeeIin", "title": "Сотрудник: ИИН", "type": "Text" },
|
||||
{ "internalName": "EmployeeCode", "title": "Сотрудник: табельный номер", "type": "Text" },
|
||||
{ "internalName": "EmployeeEmail", "title": "Сотрудник: e-mail", "type": "Text" },
|
||||
{ "internalName": "EmployeeCompany", "title": "Сотрудник: организация", "type": "Text" },
|
||||
{ "internalName": "EmployeePosition", "title": "Сотрудник: должность", "type": "Text" },
|
||||
{ "internalName": "EmployeeDepartment", "title": "Сотрудник: подразделение", "type": "Text" },
|
||||
|
||||
{ "internalName": "ChangeTypes", "title": "Вид изменения", "type": "Choice", "allowMultiple": true, "choices": [
|
||||
"Повышение должностного оклада",
|
||||
"Установление надбавки / доплаты",
|
||||
"Перевод на другую должность (постоянный)",
|
||||
"Перевод в другое подразделение",
|
||||
"Изменение места работы (другая местность)",
|
||||
"Продление срока трудового договора",
|
||||
"Изменение режима / графика работы по соглашению сторон; неполное рабочее время по просьбе работника",
|
||||
"Изменение трудовой функции (без смены должности)",
|
||||
"Изменение условий труда по инициативе работодателя по причинам экономического, технологического характера, изменений в организации и (или) сокращения объёма работ (снижение оклада, сокращение рабочего времени, изменение графика)",
|
||||
"Временный перевод при производственной необходимости / простое"
|
||||
] },
|
||||
|
||||
{ "internalName": "SalaryIncreaseCurrent", "title": "Повышение оклада: текущий оклад", "type": "Number", "decimals": 0 },
|
||||
{ "internalName": "SalaryIncreaseNew", "title": "Повышение оклада: новый оклад", "type": "Number", "decimals": 0 },
|
||||
{ "internalName": "SalaryIncreaseDate", "title": "Повышение оклада: дата изменения", "type": "DateTime", "dateOnly": true },
|
||||
{ "internalName": "SalaryIncreaseReason", "title": "Повышение оклада: причина", "type": "Choice", "choices": [
|
||||
"Успешное прохождение испытательного срока",
|
||||
"Расширение функционала / зоны ответственности",
|
||||
"Иное"
|
||||
] },
|
||||
{ "internalName": "SalaryIncreaseJustification", "title": "Повышение оклада: обоснование", "type": "Note" },
|
||||
|
||||
{ "internalName": "AllowanceReason", "title": "Надбавка/доплата: основание", "type": "Choice", "choices": [
|
||||
"Надбавка: наставничество и обучение новых работников",
|
||||
"Надбавка: разъездной характер работы",
|
||||
"Доплата: совмещение должностей / расширение зоны обслуживания",
|
||||
"Доплата: работа в тяжёлых, вредных и (или) опасных условиях труда",
|
||||
"Доплата: иное по решению первого руководителя"
|
||||
] },
|
||||
{ "internalName": "AllowanceStartDate", "title": "Надбавка/доплата: дата начала", "type": "DateTime", "dateOnly": true },
|
||||
{ "internalName": "AllowanceEndDate", "title": "Надбавка/доплата: дата окончания", "type": "DateTime", "dateOnly": true },
|
||||
{ "internalName": "AllowanceIndefinite", "title": "Надбавка/доплата: бессрочно", "type": "Boolean" },
|
||||
{ "internalName": "AllowanceAmount", "title": "Надбавка/доплата: размер", "type": "Number", "decimals": 2 },
|
||||
{ "internalName": "AllowanceUnit", "title": "Надбавка/доплата: единица размера", "type": "Choice", "choices": ["%", "₸"] },
|
||||
{ "internalName": "AllowanceJustification", "title": "Надбавка/доплата: обоснование", "type": "Note" },
|
||||
|
||||
{ "internalName": "PositionTransferNewPosition", "title": "Перевод на должность: новая должность", "type": "Text" },
|
||||
{ "internalName": "PositionTransferDate", "title": "Перевод на должность: дата перевода", "type": "DateTime", "dateOnly": true },
|
||||
{ "internalName": "PositionTransferReason", "title": "Перевод на должность: причина", "type": "Choice", "choices": [
|
||||
"Карьерный рост (успешное выполнение обязанностей, высокие результаты)",
|
||||
"Вакантная должность, соответствующая квалификации работника",
|
||||
"Открытие нового объекта / проекта, перераспределение функций",
|
||||
"Иное"
|
||||
] },
|
||||
{ "internalName": "PositionTransferJustification", "title": "Перевод на должность: обоснование", "type": "Note" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
// Пояснение под полем «Сотрудник» (маршрут по заявителю — см. ROUTE_BY_APPLICANT_NOTE).
|
||||
.note {
|
||||
margin-top: $sp-1;
|
||||
font-size: $fs-label;
|
||||
color: $gray-500;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as React from 'react';
|
||||
import { FormGrid, FormField, Select, ISelectOption } from '../../../shared/ui';
|
||||
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||
import {
|
||||
EmployeeFullBasePicker,
|
||||
formatOrganizationLine,
|
||||
personFromWorkplace,
|
||||
workplaceKey
|
||||
} from '../../../shared/dictionary';
|
||||
import { ChangeType, CHANGE_TYPE_OPTIONS } from '../changeTypes';
|
||||
import {
|
||||
ISectionProps,
|
||||
EMPLOYEE_PLACEHOLDER,
|
||||
WORKPLACE_PLACEHOLDER,
|
||||
CHANGE_TYPE_PLACEHOLDER,
|
||||
ROUTE_BY_APPLICANT_NOTE
|
||||
} from '../model';
|
||||
import { IContractEmployee } from '../useContractEmployee';
|
||||
import styles from './RequestSection.module.scss';
|
||||
|
||||
interface IRequestSectionProps extends ISectionProps {
|
||||
/** Выбор сотрудника/организации — только на форме подачи; в просмотре не передаётся. */
|
||||
employeeControl?: IContractEmployee;
|
||||
}
|
||||
|
||||
const NOOP = (): void => undefined;
|
||||
|
||||
/**
|
||||
* Общая часть заявки, не зависящая от вида изменения: чьи условия ТД меняются,
|
||||
* в какой организации (у совместителя их несколько) и что именно меняется.
|
||||
* Поля конкретных видов — отдельными секциями (byType/).
|
||||
*/
|
||||
export const RequestSection: React.FC<IRequestSectionProps> = ({ data, set, context, employeeControl }) => {
|
||||
const readOnly = useFormReadOnly();
|
||||
// В просмотре человека нет в состоянии — собираем из сохранённого места работы.
|
||||
const person = employeeControl?.person ?? (data.Employee ? personFromWorkplace(data.Employee) : undefined);
|
||||
const workplaceOptions: ISelectOption[] = (person?.workplaces ?? []).map((w) => ({
|
||||
value: workplaceKey(w),
|
||||
label: formatOrganizationLine(w.company, w.position, w.department)
|
||||
}));
|
||||
const hasNoAccount = !!person && !person.email;
|
||||
|
||||
// По ТЗ вид один (MAX_CHANGE_TYPES), поэтому обычный Select: значение — первый
|
||||
// элемент массива, выбор заменяет массив целиком. Под несколько видов здесь
|
||||
// встанет MultiChoiceField, модель и SP уже готовы.
|
||||
const selectedType = data.ChangeTypes[0] || '';
|
||||
const handleTypeChange = (value: string): void => {
|
||||
set('ChangeTypes', value ? [value as ChangeType] : []);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormGrid>
|
||||
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
||||
<EmployeeFullBasePicker
|
||||
context={context}
|
||||
id="Employee"
|
||||
value={person}
|
||||
onChange={employeeControl ? employeeControl.onPersonChange : NOOP}
|
||||
placeholder={EMPLOYEE_PLACEHOLDER}
|
||||
/>
|
||||
{!readOnly && hasNoAccount && <div className={styles.note}>{ROUTE_BY_APPLICANT_NOTE}</div>}
|
||||
</FormField>
|
||||
{workplaceOptions.length > 0 && (
|
||||
<FormField label="Организация" htmlFor="EmployeeWorkplace" required full>
|
||||
<Select
|
||||
id="EmployeeWorkplace"
|
||||
value={data.Employee ? workplaceKey(data.Employee) : ''}
|
||||
onChange={employeeControl ? employeeControl.onWorkplaceChange : NOOP}
|
||||
options={workplaceOptions}
|
||||
placeholder={WORKPLACE_PLACEHOLDER}
|
||||
// Единственное место работы выбрано автоматически — выбирать не из чего (как в командировке).
|
||||
readOnly={workplaceOptions.length === 1 && !!data.Employee}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<FormField label="Вид изменения" htmlFor="ChangeTypes" required full>
|
||||
<Select
|
||||
id="ChangeTypes"
|
||||
value={selectedType}
|
||||
onChange={handleTypeChange}
|
||||
options={CHANGE_TYPE_OPTIONS}
|
||||
placeholder={CHANGE_TYPE_PLACEHOLDER}
|
||||
/>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
|
||||
import { IEmployeeFullBaseRecord } from '../../shared/dictionary';
|
||||
import { CHANGE_TYPE_LABELS } from './changeTypes';
|
||||
import { FORM_ID, IFormData } from './model';
|
||||
|
||||
/**
|
||||
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
|
||||
* approvalServiceFor (см. shared/approval/approvalServiceFactory.ts).
|
||||
*/
|
||||
export function contractChangeApprovalService(listId?: string): ApprovalListService {
|
||||
return approvalServiceFor(FORM_ID, listId);
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return n < 10 ? `0${n}` : `${n}`;
|
||||
}
|
||||
|
||||
/** Осмысленный заголовок элемента (системная колонка Title). */
|
||||
function buildTitle(employeeFio: string): string {
|
||||
const now = new Date();
|
||||
const date = `${pad2(now.getDate())}.${pad2(now.getMonth() + 1)}.${now.getFullYear()}`;
|
||||
return `Изменение условий ТД — ${employeeFio || 'сотрудник не выбран'} — ${date}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Снимок сотрудника → текстовые колонки Employee* (см. provisioning.json).
|
||||
* Не выбран (черновик) — все колонки очищаются null (так SharePoint REST очищает
|
||||
* поле), а не остаются от прошлого выбора.
|
||||
*/
|
||||
function employeeColumns(employee: IEmployeeFullBaseRecord | undefined): Record<string, unknown> {
|
||||
return {
|
||||
EmployeeRecordId: employee?.id || null,
|
||||
EmployeeFio: employee?.fio || null,
|
||||
EmployeeIin: employee?.iin || null,
|
||||
EmployeeCode: employee?.code || null,
|
||||
EmployeeEmail: employee?.email || null,
|
||||
EmployeeCompany: employee?.company || null,
|
||||
EmployeePosition: employee?.position || null,
|
||||
EmployeeDepartment: employee?.department || null
|
||||
};
|
||||
}
|
||||
|
||||
/** «Сырое» число строкой (сумма или процент) → число для Number-колонки; '' или мусор → undefined. */
|
||||
function numberColumn(raw: string): number | undefined {
|
||||
const amount = Number(raw);
|
||||
return raw.trim() !== '' && Number.isFinite(amount) ? amount : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Колонки вида «Повышение оклада». Вид не выбран — колонки очищаются: иначе
|
||||
* после смены вида в заявке остались бы скрытые поля прежнего, которых никто не видит.
|
||||
*/
|
||||
function salaryIncreaseColumns(data: IFormData): Record<string, unknown> {
|
||||
if (!data.ChangeTypes.includes('salary-increase')) {
|
||||
return {
|
||||
SalaryIncreaseCurrent: null,
|
||||
SalaryIncreaseNew: null,
|
||||
SalaryIncreaseDate: null,
|
||||
SalaryIncreaseReason: null,
|
||||
SalaryIncreaseJustification: null
|
||||
};
|
||||
}
|
||||
return {
|
||||
SalaryIncreaseCurrent: numberColumn(data.SalaryIncreaseCurrent) ?? null,
|
||||
SalaryIncreaseNew: numberColumn(data.SalaryIncreaseNew) ?? null,
|
||||
SalaryIncreaseDate: data.SalaryIncreaseDate || null,
|
||||
SalaryIncreaseReason: data.SalaryIncreaseReason || null,
|
||||
SalaryIncreaseJustification: data.SalaryIncreaseJustification || null
|
||||
};
|
||||
}
|
||||
|
||||
/** Колонки вида «Надбавка / доплата»; вид не выбран — очищаются (см. salaryIncreaseColumns). */
|
||||
function allowanceColumns(data: IFormData): Record<string, unknown> {
|
||||
if (!data.ChangeTypes.includes('allowance')) {
|
||||
return {
|
||||
AllowanceReason: null,
|
||||
AllowanceStartDate: null,
|
||||
AllowanceEndDate: null,
|
||||
AllowanceIndefinite: null,
|
||||
AllowanceAmount: null,
|
||||
AllowanceUnit: null,
|
||||
AllowanceJustification: null
|
||||
};
|
||||
}
|
||||
return {
|
||||
AllowanceReason: data.AllowanceReason || null,
|
||||
AllowanceStartDate: data.AllowanceStartDate || null,
|
||||
// Бессрочно — даты окончания нет, даже если она осталась в состоянии формы.
|
||||
AllowanceEndDate: data.AllowanceIndefinite ? null : data.AllowanceEndDate || null,
|
||||
AllowanceIndefinite: data.AllowanceIndefinite,
|
||||
AllowanceAmount: numberColumn(data.AllowanceAmount) ?? null,
|
||||
AllowanceUnit: data.AllowanceUnit,
|
||||
AllowanceJustification: data.AllowanceJustification || null
|
||||
};
|
||||
}
|
||||
|
||||
/** Колонки вида «Перевод на другую должность»; вид не выбран — очищаются (см. salaryIncreaseColumns). */
|
||||
function positionTransferColumns(data: IFormData): Record<string, unknown> {
|
||||
if (!data.ChangeTypes.includes('position-transfer')) {
|
||||
return {
|
||||
PositionTransferNewPosition: null,
|
||||
PositionTransferDate: null,
|
||||
PositionTransferReason: null,
|
||||
PositionTransferJustification: null
|
||||
};
|
||||
}
|
||||
return {
|
||||
PositionTransferNewPosition: data.PositionTransferNewPosition.trim() || null,
|
||||
PositionTransferDate: data.PositionTransferDate || null,
|
||||
PositionTransferReason: data.PositionTransferReason || null,
|
||||
PositionTransferJustification: data.PositionTransferJustification || null
|
||||
};
|
||||
}
|
||||
|
||||
/** Поля элемента списка данных из модели формы. */
|
||||
function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
||||
return {
|
||||
Title: buildTitle(data.Employee?.fio || ''),
|
||||
RequestStatus: status,
|
||||
...employeeColumns(data.Employee),
|
||||
// MultiChoice пишется плоским массивом подписей (как «База 1С» в Ticket1C); [] очищает колонку.
|
||||
ChangeTypes: data.ChangeTypes.map((type) => CHANGE_TYPE_LABELS[type]),
|
||||
...salaryIncreaseColumns(data),
|
||||
...allowanceColumns(data),
|
||||
...positionTransferColumns(data)
|
||||
};
|
||||
}
|
||||
|
||||
export type ISaveResult = ISaveItemResult;
|
||||
|
||||
/**
|
||||
* Сохранить заявку: содержимое + маршрут согласования.
|
||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||
* executors — фаза «Исполнение» маршрута из админки (пусто, если её нет).
|
||||
*
|
||||
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadContractChangeRequest); новая
|
||||
* заявка всегда создаётся в текущей партиции.
|
||||
*/
|
||||
export async function saveContractChangeRequest(
|
||||
existingId: number | undefined,
|
||||
requestTypeId: number,
|
||||
data: IFormData,
|
||||
approvers: IApproverToWrite[],
|
||||
executors: IApproverToWrite[],
|
||||
status: string,
|
||||
listId?: string
|
||||
): Promise<ISaveResult> {
|
||||
const item = buildItem(data, status);
|
||||
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||
await writeRoute(contractChangeApprovalService(listId), result.id, approvers, executors, status);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import {
|
||||
IEmployeeFullBasePerson,
|
||||
IEmployeeFullBaseRecord,
|
||||
getEmployeeOrganizations,
|
||||
groupEmployeesByPerson,
|
||||
personFromWorkplace,
|
||||
personKey,
|
||||
searchActiveEmployees,
|
||||
workplaceKey
|
||||
} from '../../shared/dictionary';
|
||||
|
||||
// Совпадений по Ф.И.О. немного (тёзки × места работы) — запас с лихвой.
|
||||
const MAX_NAMESAKE_RECORDS = 60;
|
||||
|
||||
/** Человек + выбор его места работы — то, что нужно секции «Сотрудник / Организация». */
|
||||
export interface IContractEmployee {
|
||||
person: IEmployeeFullBasePerson | undefined;
|
||||
onPersonChange: (person: IEmployeeFullBasePerson | undefined) => void;
|
||||
/** key — workplaceKey выбранного места работы. */
|
||||
onWorkplaceChange: (key: string) => void;
|
||||
}
|
||||
|
||||
/** Место работы, если выбирать не из чего (оно одно) — иначе выбирает пользователь. */
|
||||
function singleWorkplace(person: IEmployeeFullBasePerson): IEmployeeFullBaseRecord | undefined {
|
||||
return person.workplaces.length === 1 ? person.workplaces[0] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Себя — по e-mail. Параметра Email у FullBase нет, поэтому: Ф.И.О. из
|
||||
* Employee/ByEmail → поиск по Ф.И.О. → человек, у кого есть место работы с этим e-mail.
|
||||
*/
|
||||
async function findPersonByEmail(context: WebPartContext, email: string): Promise<IEmployeeFullBasePerson | undefined> {
|
||||
const organizations = await getEmployeeOrganizations(context, email);
|
||||
const fio = organizations[0]?.fio || '';
|
||||
if (!fio) return undefined;
|
||||
const people = groupEmployeesByPerson(await searchActiveEmployees(context, fio, MAX_NAMESAKE_RECORDS));
|
||||
const target = email.toLowerCase();
|
||||
return people.find((p) => p.workplaces.some((w) => w.email.toLowerCase() === target));
|
||||
}
|
||||
|
||||
/** Все места работы человека, чьё одно место сохранено в заявке (черновик/возврат). */
|
||||
async function findPersonOfWorkplace(
|
||||
context: WebPartContext,
|
||||
record: IEmployeeFullBaseRecord
|
||||
): Promise<IEmployeeFullBasePerson | undefined> {
|
||||
const people = groupEmployeesByPerson(await searchActiveEmployees(context, record.fio, MAX_NAMESAKE_RECORDS));
|
||||
return people.find((p) => p.key === personKey(record));
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбор сотрудника и его организации, как в «Заявке на командировку»: человек —
|
||||
* пикером, место работы — списком; одно место выбирается само.
|
||||
*
|
||||
* В заявке хранится только выбранное место работы (setEmployee); человек со
|
||||
* всеми местами — состояние формы, при открытии черновика подтягивается заново.
|
||||
*/
|
||||
export function useContractEmployee(
|
||||
context: WebPartContext,
|
||||
applicantEmail: string,
|
||||
isNew: boolean,
|
||||
employee: IEmployeeFullBaseRecord | undefined,
|
||||
setEmployee: (record: IEmployeeFullBaseRecord | undefined) => void
|
||||
): IContractEmployee {
|
||||
const [person, setPerson] = useState<IEmployeeFullBasePerson | undefined>(undefined);
|
||||
// Пользователь уже выбрал сам — фоновые поиски его выбор не перетирают.
|
||||
const isTouchedRef = useRef(false);
|
||||
|
||||
const applyPerson = (next: IEmployeeFullBasePerson | undefined): void => {
|
||||
setPerson(next);
|
||||
setEmployee(next ? singleWorkplace(next) : undefined);
|
||||
};
|
||||
|
||||
// Новая заявка — по умолчанию на себя.
|
||||
useEffect(() => {
|
||||
if (!isNew || !applicantEmail) return;
|
||||
let alive = true;
|
||||
findPersonByEmail(context, applicantEmail)
|
||||
.then((found) => {
|
||||
if (alive && found && !isTouchedRef.current) applyPerson(found);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [context, applicantEmail, isNew]);
|
||||
|
||||
// Черновик/возврат: восстановить список организаций; не нашлось — хотя бы сохранённая.
|
||||
const restoreKey = !isNew && employee && !person ? personKey(employee) : '';
|
||||
useEffect(() => {
|
||||
if (!restoreKey || !employee) return;
|
||||
let alive = true;
|
||||
findPersonOfWorkplace(context, employee)
|
||||
.then((found) => {
|
||||
if (alive && !isTouchedRef.current) setPerson(found ?? personFromWorkplace(employee));
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive && !isTouchedRef.current) setPerson(personFromWorkplace(employee));
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [context, restoreKey]);
|
||||
|
||||
return {
|
||||
person,
|
||||
onPersonChange: (next) => {
|
||||
isTouchedRef.current = true;
|
||||
applyPerson(next);
|
||||
},
|
||||
onWorkplaceChange: (key) => {
|
||||
setEmployee(person?.workplaces.find((w) => workplaceKey(w) === key));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
notifyInitiatorOfReturn,
|
||||
useRequestTypeId,
|
||||
REQUEST_STATUS,
|
||||
approvalPhaseRows,
|
||||
@@ -218,16 +219,19 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
||||
await updateAssetsAcceptor(itemId, data.AssetsAcceptor, listId);
|
||||
};
|
||||
|
||||
const notify = (): void => {
|
||||
const notify = (newStatus?: string): void => {
|
||||
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
||||
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
||||
// весь UI ради необязательного оповещения.
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
const target = {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
// Заявление согласовано целиком → сразу заводим обходной лист по нему (данные
|
||||
@@ -311,7 +315,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
||||
onDecided={(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
notify(newStatus);
|
||||
// «Согласовано» = маршрут заявления пройден целиком (фазы исполнения у
|
||||
// него нет, см. README формы) — значит увольнение подтверждено и пора
|
||||
// заводить обходной лист. Промис ВОЗВРАЩАЕМ: пока он не разрешится,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ExecutorsMatrix,
|
||||
approvalPhaseRows,
|
||||
notifyActiveStep,
|
||||
notifyInitiatorOfReturn,
|
||||
useRequestTypeId
|
||||
} from '../../shared/approval';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
@@ -117,16 +118,19 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
|
||||
|
||||
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
|
||||
|
||||
const notify = (): void => {
|
||||
const notify = (newStatus?: string): void => {
|
||||
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
||||
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
||||
// весь UI ради необязательного оповещения.
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
const target = {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -172,7 +176,7 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
|
||||
onDecided={(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
notify(newStatus);
|
||||
}}
|
||||
onDelegated={() => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { AttachmentList, FileDropzone, FormField } from '../../shared/ui';
|
||||
import { IAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
|
||||
import { attachmentsOfCategory, categoryPrefix, uncategorizedAttachments } from './attachmentCategories';
|
||||
import styles from './HireAttachmentField.module.scss';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
editor: IAttachmentEditor;
|
||||
editable: boolean;
|
||||
/** Группа новых файлов в editor.pending. */
|
||||
groupKey: string;
|
||||
/** Категория в имени файла; не задана — «прочие» вложения без префикса. */
|
||||
category?: string;
|
||||
}
|
||||
|
||||
/** Поле одной категории вложений: прикреплённые файлы + (в правке) выбор новых. */
|
||||
export const HireAttachmentField: React.FC<Props> = ({ label, editor, editable, groupKey, category }) => {
|
||||
const items = category ? attachmentsOfCategory(editor.items, category) : uncategorizedAttachments(editor.items);
|
||||
// В списке имена показаны без префикса категории — для удаления возвращаем его.
|
||||
const removeWithPrefix = (name: string): void => editor.remove(category ? categoryPrefix(category) + name : name);
|
||||
|
||||
return (
|
||||
<FormField label={label} full>
|
||||
{items.length > 0 && <AttachmentList items={items} onRemove={editable ? removeWithPrefix : undefined} />}
|
||||
{items.length === 0 && !editable && <span className={styles.empty}>—</span>}
|
||||
{editable && (
|
||||
<FileDropzone files={editor.pending[groupKey] || []} onChange={(files) => editor.setPending(groupKey, files)} />
|
||||
)}
|
||||
</FormField>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import { Alert, CollapseSection } from '../../shared/ui';
|
||||
import { IAttachmentEditor, PrepareFiles } from '../../shared/attachments/useAttachmentEditor';
|
||||
import { AttachmentSlotField } from '../../shared/attachments/AttachmentSlotField';
|
||||
import { ATT_CATEGORY_ID, ATT_CATEGORY_RESUME, prefixFiles, uncategorizedAttachments } from './attachmentCategories';
|
||||
import { HireAttachmentField } from './HireAttachmentField';
|
||||
|
||||
/**
|
||||
* Группы новых файлов → категория в имени файла (см. attachmentCategories).
|
||||
@@ -37,9 +37,16 @@ export const HireAttachments: React.FC<Props> = ({ editor, editable }) => {
|
||||
return (
|
||||
<CollapseSection title="Вложения" icon="upload">
|
||||
{editor.error && <Alert variant="err">{editor.error}</Alert>}
|
||||
<HireAttachmentField label="Удостоверение" groupKey={GROUP_ID} category={ATT_CATEGORY_ID} {...fieldProps} />
|
||||
<HireAttachmentField label="Резюме" groupKey={GROUP_RESUME} category={ATT_CATEGORY_RESUME} {...fieldProps} />
|
||||
{showOther && <HireAttachmentField label="Прочие вложения" groupKey={GROUP_OTHER} {...fieldProps} />}
|
||||
<AttachmentSlotField label="Удостоверение" groupKey={GROUP_ID} category={ATT_CATEGORY_ID} {...fieldProps} />
|
||||
<AttachmentSlotField label="Резюме" groupKey={GROUP_RESUME} category={ATT_CATEGORY_RESUME} {...fieldProps} />
|
||||
{showOther && (
|
||||
<AttachmentSlotField
|
||||
label="Прочие вложения"
|
||||
groupKey={GROUP_OTHER}
|
||||
knownCategories={[ATT_CATEGORY_ID, ATT_CATEGORY_RESUME]}
|
||||
{...fieldProps}
|
||||
/>
|
||||
)}
|
||||
</CollapseSection>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
notifyInitiatorOfReturn,
|
||||
useRequestTypeId,
|
||||
APPROVAL_PHASE,
|
||||
isApprovedStatus,
|
||||
@@ -283,13 +284,16 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
);
|
||||
|
||||
// Выше ранних return (загрузка/ошибка): зовётся и из колбэков хуков ниже.
|
||||
const notify = (): void => {
|
||||
const notify = (newStatus?: string): void => {
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
const target = {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
// Добавить согласующего/исполнителя в поданную заявку — то же право
|
||||
@@ -479,7 +483,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
// Очередь сдвинулась — уведомляем следующий активный шаг.
|
||||
notify();
|
||||
notify(newStatus);
|
||||
}}
|
||||
onDelegated={() => {
|
||||
// Шаг переназначен: активной стала строка делегата — обновляем
|
||||
|
||||
@@ -1,50 +1,24 @@
|
||||
import { IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||
import { attachmentsWithoutCategories } from '../../shared/attachments/attachmentCategories';
|
||||
|
||||
/**
|
||||
* Категории вложений заявки на прием. Вложения SharePoint — плоская коллекция
|
||||
* файлов на элементе списка, «категории» у них нет. Поэтому категорию кодируем
|
||||
* ПРЕФИКСОМ в имени файла: «<Категория> — <исходное имя>». При загрузке имя
|
||||
* файла переписываем, при показе — группируем по префиксу и префикс срезаем.
|
||||
* Категории вложений заявки на прием. Механизм подписи префиксом в имени файла —
|
||||
* общий (shared/attachments/attachmentCategories); здесь только категории этой
|
||||
* формы. Общие функции реэкспортируются, чтобы импорты формы не менялись.
|
||||
*/
|
||||
export const ATT_CATEGORY_ID = 'Удостоверение';
|
||||
export const ATT_CATEGORY_RESUME = 'Резюме';
|
||||
|
||||
const SEP = ' — ';
|
||||
|
||||
/** Префикс имени файла для категории: «Удостоверение — ». */
|
||||
export function categoryPrefix(category: string): string {
|
||||
return `${category}${SEP}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Копии File с префиксом категории в имени — для загрузки. File.name неизменяемо,
|
||||
* поэтому создаём новый File с тем же содержимым и типом, но другим именем.
|
||||
*/
|
||||
export function prefixFiles(files: File[], category: string): File[] {
|
||||
const prefix = categoryPrefix(category);
|
||||
return files.map((f) => new File([f], prefix + f.name, { type: f.type }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Вложения указанной категории, с ОБРЕЗАННЫМ префиксом в имени (для показа).
|
||||
* url сохраняется как есть; чтобы удалить такой файл, префикс надо вернуть
|
||||
* обратно (см. categoryPrefix в обработчике удаления формы).
|
||||
*/
|
||||
export function attachmentsOfCategory(items: IAttachmentInfo[], category: string): IAttachmentInfo[] {
|
||||
const prefix = categoryPrefix(category);
|
||||
return items
|
||||
.filter((a) => a.fileName.indexOf(prefix) === 0)
|
||||
.map((a) => ({ url: a.url, fileName: a.fileName.slice(prefix.length) }));
|
||||
}
|
||||
export {
|
||||
categoryPrefix,
|
||||
prefixFiles,
|
||||
attachmentsOfCategory
|
||||
} from '../../shared/attachments/attachmentCategories';
|
||||
|
||||
/**
|
||||
* Вложения без известной категории (старые заявки, загруженные до разделения).
|
||||
* Показываем как есть, чтобы файлы не «исчезали» с экрана.
|
||||
*/
|
||||
export function uncategorizedAttachments(items: IAttachmentInfo[]): IAttachmentInfo[] {
|
||||
const idPrefix = categoryPrefix(ATT_CATEGORY_ID);
|
||||
const resumePrefix = categoryPrefix(ATT_CATEGORY_RESUME);
|
||||
return items.filter(
|
||||
(a) => a.fileName.indexOf(idPrefix) !== 0 && a.fileName.indexOf(resumePrefix) !== 0
|
||||
);
|
||||
return attachmentsWithoutCategories(items, [ATT_CATEGORY_ID, ATT_CATEGORY_RESUME]);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
notifyInitiatorOfReturn,
|
||||
useRequestTypeId,
|
||||
isApprovedStatus,
|
||||
resolveApproverOrgInfo,
|
||||
@@ -204,20 +205,25 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
await saveAllFields();
|
||||
};
|
||||
|
||||
// rowId — оповестить конкретную активную строку (см. notifyActiveStep).
|
||||
const notify = (rowId?: number): void => {
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(
|
||||
context,
|
||||
approval,
|
||||
itemId,
|
||||
{
|
||||
// Куда смотрит поток оповещения; undefined — тип заявки ещё не резолвился.
|
||||
const notifyTarget = requestTypeId
|
||||
? {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
},
|
||||
rowId
|
||||
).catch(() => undefined);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// rowId — оповестить конкретную активную строку (см. notifyActiveStep).
|
||||
const notify = (rowId?: number): void => {
|
||||
if (!notifyTarget) return;
|
||||
notifyActiveStep(context, approval, itemId, notifyTarget, rowId).catch(() => undefined);
|
||||
};
|
||||
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
const notifyInitiator = (newStatus: string): void => {
|
||||
if (!notifyTarget) return;
|
||||
notifyInitiatorOfReturn(context, approval, itemId, notifyTarget, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
// Удалить ещё не решённый шаг маршрута (см. ApproverService.deleteQueuedStep).
|
||||
@@ -467,6 +473,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
notifyInitiator(newStatus);
|
||||
}}
|
||||
onDelegated={() => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
notifyInitiatorOfReturn,
|
||||
useRequestTypeId,
|
||||
APPROVAL_PHASE,
|
||||
isApprovedStatus,
|
||||
@@ -169,16 +170,19 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
|
||||
|
||||
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
|
||||
|
||||
const notify = (): void => {
|
||||
const notify = (newStatus?: string): void => {
|
||||
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
||||
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
||||
// весь UI ради необязательного оповещения.
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
const target = {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -253,7 +257,7 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
|
||||
onDecided={(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
notify(newStatus);
|
||||
}}
|
||||
onDelegated={() => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ExecutorsMatrix,
|
||||
approvalPhaseRows,
|
||||
notifyActiveStep,
|
||||
notifyInitiatorOfReturn,
|
||||
useRequestTypeId,
|
||||
isEditableStatus
|
||||
} from '../../shared/approval';
|
||||
@@ -141,13 +142,16 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
|
||||
|
||||
const sectionProps = { data, set, context, printOrgIndex, attachmentUrls };
|
||||
|
||||
const notify = (): void => {
|
||||
const notify = (newStatus?: string): void => {
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
const target = {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -205,7 +209,7 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
|
||||
onDecided={(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
notify(newStatus);
|
||||
}}
|
||||
onDelegated={() => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
StatusBadge,
|
||||
ApprovalHistory,
|
||||
notifyActiveStep,
|
||||
notifyInitiatorOfReturn,
|
||||
useRequestTypeId,
|
||||
usePhasedRoute
|
||||
} from '../../shared/approval';
|
||||
@@ -110,14 +111,17 @@ const SkudExplanationView: React.FC<ISkudExplanationViewProps> = ({ context, ite
|
||||
);
|
||||
}
|
||||
|
||||
const notify = (): void => {
|
||||
const notify = (newStatus?: string): void => {
|
||||
// Решение уже применено; без requestTypeId просто некого уведомлять — UI не роняем.
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
const target = {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
|
||||
@@ -175,7 +179,7 @@ const SkudExplanationView: React.FC<ISkudExplanationViewProps> = ({ context, ite
|
||||
onDecided={(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
notify(newStatus);
|
||||
}}
|
||||
onDelegated={() => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
notifyInitiatorOfReturn,
|
||||
useRequestTypeId,
|
||||
isEditableStatus,
|
||||
APPROVAL_PHASE
|
||||
@@ -102,20 +103,25 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
||||
approval.getJobTitle(l)
|
||||
);
|
||||
|
||||
// rowId — оповестить конкретную активную строку (см. notifyActiveStep).
|
||||
const notify = (rowId?: number): void => {
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(
|
||||
context,
|
||||
approval,
|
||||
itemId,
|
||||
{
|
||||
// Куда смотрит поток оповещения; undefined — тип заявки ещё не резолвился.
|
||||
const notifyTarget = requestTypeId
|
||||
? {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
},
|
||||
rowId
|
||||
).catch(() => undefined);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// rowId — оповестить конкретную активную строку (см. notifyActiveStep).
|
||||
const notify = (rowId?: number): void => {
|
||||
if (!notifyTarget) return;
|
||||
notifyActiveStep(context, approval, itemId, notifyTarget, rowId).catch(() => undefined);
|
||||
};
|
||||
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
const notifyInitiator = (newStatus: string): void => {
|
||||
if (!notifyTarget) return;
|
||||
notifyInitiatorOfReturn(context, approval, itemId, notifyTarget, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
const executorEditing = useExecutorEditing({
|
||||
@@ -246,6 +252,7 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
||||
setDecisionSeq((n) => n + 1);
|
||||
// Очередь сдвинулась — уведомляем следующий активный шаг.
|
||||
notify();
|
||||
notifyInitiator(newStatus);
|
||||
|
||||
// Дошла до исполнения / завершилась / отклонена — карточка в Planner
|
||||
// (повторно не создаётся, см. plannerHandoff.ts).
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
notifyInitiatorOfReturn,
|
||||
useRequestTypeId,
|
||||
isEditableStatus,
|
||||
APPROVAL_PHASE
|
||||
@@ -144,13 +145,16 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
|
||||
|
||||
const sectionProps = { data, set, context, printOrgIndex };
|
||||
|
||||
const notify = (): void => {
|
||||
const notify = (newStatus?: string): void => {
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
const target = {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -222,7 +226,7 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
|
||||
onDecided={(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
notify(newStatus);
|
||||
}}
|
||||
onDelegated={() => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
useViewExecutors,
|
||||
approvalPhaseRows,
|
||||
notifyActiveStep,
|
||||
notifyInitiatorOfReturn,
|
||||
useRequestTypeId
|
||||
} from '../../shared/approval';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
@@ -125,16 +126,19 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
||||
|
||||
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
|
||||
|
||||
const notify = (): void => {
|
||||
const notify = (newStatus?: string): void => {
|
||||
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
||||
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
||||
// весь UI ради необязательного оповещения.
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
const target = {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -182,7 +186,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
||||
onDecided={(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
notify(newStatus);
|
||||
}}
|
||||
onDelegated={() => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
праздники и выходные. Колонка `EndDate` не используется: из провижининга, типа контента и представления она убрана (само системное поле SharePoint `FromBaseType` удалить нельзя).
|
||||
- **Заголовок элемента** (`Title`): `Отпуск — {ФИО} — дд.мм.гггг`.
|
||||
- **Комментарий** (с 10-28, `VacationComment`, Note) — необязательный, заполняет инициатор,
|
||||
последним полем секции; в просмотре пустой не показывается. В DOCX для ЭДО (`vacationDocument.ts`) не идёт; при печати
|
||||
просмотра виден, как остальные поля секции.
|
||||
последним полем секции; в просмотре пустой не показывается. В DOCX для ЭДО (`vacationDocument.ts`) не идёт; в печать
|
||||
тоже не попадает (обёртка `rp-no-print` с `display: contents`, чтобы на экране поле оставалось ячейкой сетки).
|
||||
|
||||
## Подписание в ЭДО
|
||||
> **Сейчас выключено** (`VACATION_EDO_SIGNING_ENABLED = false` в `shared/config.ts`): решение последнего
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ExecutorsMatrix,
|
||||
approvalPhaseRows,
|
||||
notifyActiveStep,
|
||||
notifyInitiatorOfReturn,
|
||||
useRequestTypeId
|
||||
} from '../../shared/approval';
|
||||
import { completesApprovalPhase } from '../../shared/approval/models/statuses';
|
||||
@@ -143,16 +144,19 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
||||
|
||||
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
|
||||
|
||||
const notify = (): void => {
|
||||
const notify = (newStatus?: string): void => {
|
||||
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
||||
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
||||
// весь UI ради необязательного оповещения.
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
const target = {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
// Заявление уходит на подпись в ЭДО (по документу на каждую компанию сотрудника, одним запросом).
|
||||
@@ -263,7 +267,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
||||
onDecided={(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
notify(newStatus);
|
||||
return VACATION_EDO_SIGNING_ENABLED && sentBeforeDecision.current ?'Документы отправлены на подписание в ЭДО.' : undefined;
|
||||
}}
|
||||
onDelegated={() => {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// Обёртка поля только ради класса rp-no-print: contents убирает её из раскладки,
|
||||
// поэтому поле остаётся ячейкой FormGrid и сохраняет растяжку `full`.
|
||||
.noPrintField {
|
||||
display: contents;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { FormGrid, FormField, TextInput, TextArea, PeopleField, OrganizationTabl
|
||||
import { ProjectPicker } from '../../../shared/dictionary';
|
||||
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS, MAX_DAYS } from '../model';
|
||||
import { isoDatePlusDays } from '../../../shared/dateUtils';
|
||||
import styles from './RequestSection.module.scss';
|
||||
|
||||
/** Единственная секция формы «Заявка на отпуск»: сотрудник (должность и подразделение подтягиваются из справочника), даты. */
|
||||
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => {
|
||||
@@ -59,8 +60,10 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
|
||||
placeholder={SUBSTITUTE_PLACEHOLDER}
|
||||
/>
|
||||
</FormField>
|
||||
{/* Пустой комментарий в просмотре не показываем — пустое поле только мешает читать. */}
|
||||
{/* Пустой комментарий в просмотре не показываем — пустое поле только мешает читать.
|
||||
В печатное заявление комментарий не входит — он для согласующих, не для кадров. */}
|
||||
{(!readOnly || data.VacationComment.trim()) && (
|
||||
<div className={`${styles.noPrintField} rp-no-print`}>
|
||||
<FormField label="Комментарий" htmlFor="VacationComment" full>
|
||||
<TextArea
|
||||
id="VacationComment"
|
||||
@@ -69,6 +72,7 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
|
||||
rows={3}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
</FormGrid>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
notifyInitiatorOfReturn,
|
||||
useRequestTypeId,
|
||||
isEditableStatus,
|
||||
APPROVAL_PHASE,
|
||||
@@ -224,13 +225,16 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
const sectionProps = { data, set, context, printOrgIndex };
|
||||
const canEditAnything = canEditAll || canEditReviewFields;
|
||||
|
||||
const notify = (): void => {
|
||||
const notify = (newStatus?: string): void => {
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
const target = {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -316,7 +320,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
// Очередь сдвинулась — уведомляем следующий активный шаг.
|
||||
notify();
|
||||
notify(newStatus);
|
||||
}}
|
||||
onDelegated={() => {
|
||||
// Шаг переназначен: активной стала строка делегата — обновляем
|
||||
|
||||
@@ -57,6 +57,9 @@ const formRegistry: Record<string, React.LazyExoticComponent<FormComponent>> = {
|
||||
),
|
||||
'wh-concrete-request': React.lazy(
|
||||
() => import(/* webpackChunkName: 'form-concrete-request' */ './ConcreteRequest/ConcreteRequestForm')
|
||||
),
|
||||
'hr-contract-change': React.lazy(
|
||||
() => import(/* webpackChunkName: 'form-contract-change' */ './ContractChange/ContractChangeForm')
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
@@ -76,6 +76,9 @@ const viewRegistry: Record<string, React.LazyExoticComponent<ViewComponent>> = {
|
||||
),
|
||||
'wh-concrete-request': React.lazy(
|
||||
() => import(/* webpackChunkName: 'view-concrete-request' */ './ConcreteRequest/ConcreteRequestView')
|
||||
),
|
||||
'hr-contract-change': React.lazy(
|
||||
() => import(/* webpackChunkName: 'view-contract-change' */ './ContractChange/ContractChangeView')
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export { approvalServiceFor } from './approvalServiceFactory';
|
||||
export { ApproverService } from './approverDecision.service';
|
||||
export type { ApproverDecision, IPendingRow } from './approverDecision.service';
|
||||
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
|
||||
export { notifyActiveStep, postToFlow } from './notify.service';
|
||||
export { notifyActiveStep, notifyInitiatorOfReturn, postToFlow } from './notify.service';
|
||||
export type { INotifyTarget } from './notify.service';
|
||||
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
|
||||
export type { IUseMatrixEditor } from './useMatrixEditor';
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { AadHttpClient } from '@microsoft/sp-http';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { FLOW_RESOURCE, FLOW_NOTIFY_URL, getNotificationsEnabled, getNotifyOnlyTo } from '../config';
|
||||
import { getCreatedBy } from '../createdBy';
|
||||
import { ApprovalListService } from './approvalList.service';
|
||||
import { IApprovalRow } from './models/IApprovalRow';
|
||||
import { REQUEST_STATUS } from './models/statuses';
|
||||
import { pickRowsToNotify } from './notifyTargets';
|
||||
|
||||
// Данные формы для тела запроса к потоку (постоянны для формы).
|
||||
@@ -42,11 +44,17 @@ export async function postToFlow(context: WebPartContext, flowUrl: string, body:
|
||||
// живому человеку лучше не слать».
|
||||
async function isNotifyAllowed(approval: ApprovalListService, approverId: number): Promise<boolean> {
|
||||
if (!getNotificationsEnabled()) return false;
|
||||
if (!getNotifyOnlyTo()) return true;
|
||||
return isRecipientAllowed(await approval.resolveUserEmail(approverId));
|
||||
}
|
||||
|
||||
// То же ограничение notifyOnlyTo, когда адрес получателя уже известен.
|
||||
function isRecipientAllowed(email: string): boolean {
|
||||
if (!getNotificationsEnabled()) return false;
|
||||
|
||||
const onlyTo = getNotifyOnlyTo();
|
||||
if (!onlyTo) return true;
|
||||
|
||||
const email = await approval.resolveUserEmail(approverId);
|
||||
if (email.toLowerCase() === onlyTo.toLowerCase()) return true;
|
||||
|
||||
console.warn(
|
||||
@@ -109,3 +117,51 @@ export async function notifyActiveStep(
|
||||
pickRowsToNotify(rows, rowId).map((row) => notifyRow(context, approval, requestId, target, row))
|
||||
);
|
||||
}
|
||||
|
||||
// Решения, после которых заявка уходит обратно к инициатору и ему нужно письмо.
|
||||
const RETURNED_TO_INITIATOR_STATUSES: string[] = [REQUEST_STATUS.rejected, REQUEST_STATUS.returned];
|
||||
|
||||
// Сообщить инициатору, что заявку отклонили или вернули на доработку. Вызывать
|
||||
// в onDecided с новым статусом заявки — при любом другом статусе ничего не делает.
|
||||
//
|
||||
// Поток тот же, что у notifyActiveStep: у него есть ветки «Отклонено» и «Возврат
|
||||
// на доработку», в которых он пишет не согласующему строки, а автору заявки.
|
||||
// notifyActiveStep до них не доходит — после такого решения активных строк нет.
|
||||
//
|
||||
// Строка — та, по которой решил ТЕКУЩИЙ пользователь: он только что принял
|
||||
// решение. Искать «любую отклонённую» нельзя — на параллельном шаге второй
|
||||
// участник может решить по уже отклонённой заявке, и в письме оказался бы чужой
|
||||
// комментарий.
|
||||
//
|
||||
// Некритично, как и notifyActiveStep: ошибки глотаем.
|
||||
export async function notifyInitiatorOfReturn(
|
||||
context: WebPartContext,
|
||||
approval: ApprovalListService,
|
||||
requestId: number,
|
||||
target: INotifyTarget,
|
||||
requestStatus: string
|
||||
): Promise<void> {
|
||||
if (RETURNED_TO_INITIATOR_STATUSES.indexOf(requestStatus) === -1) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [rows, userId, initiator] = await Promise.all([
|
||||
approval.getForRequest(requestId),
|
||||
approval.getCurrentUserId(),
|
||||
getCreatedBy(target.dataListName, requestId),
|
||||
]);
|
||||
const decidedRow = rows.filter((r) => r.status === requestStatus && r.approverId === userId)[0];
|
||||
if (!decidedRow || !isRecipientAllowed(initiator?.email || '')) {
|
||||
return;
|
||||
}
|
||||
await postToFlow(context, FLOW_NOTIFY_URL, {
|
||||
RequestTypeId: target.requestTypeId,
|
||||
RowID: decidedRow.id,
|
||||
RequestID: requestId,
|
||||
ListName: target.dataListName,
|
||||
ApproveListName: target.approvalListName,
|
||||
});
|
||||
} catch {
|
||||
// не критично — просто не отправили уведомление
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
// Сколько строк комментария видно до «Показать полностью». Три — короткие
|
||||
// пометки («Вместо Иванова») видны целиком, а длинная причина отказа не
|
||||
// растягивает таблицу маршрута на пол-экрана.
|
||||
$comment-visible-lines: 3;
|
||||
|
||||
// Высота первой строки текста ($fs-sm × line-height 1.45 ≈ 19px): кавычка
|
||||
// стоит вровень с ней, а не сползает вниз из-за своего крупного кегля.
|
||||
$quote-line-height: 19px;
|
||||
|
||||
.comment {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: $sp-2;
|
||||
padding: $sp-2 $sp-3;
|
||||
border-left: 3px solid;
|
||||
border-radius: 0 $r-sm $r-sm 0;
|
||||
font-size: $fs-sm;
|
||||
line-height: 1.45;
|
||||
color: $gray-900;
|
||||
}
|
||||
|
||||
// Цвет — у полосы и кавычки, фон лишь подтон: текст должен читаться так же
|
||||
// спокойно, как остальная таблица, а не кричать.
|
||||
.neutral {
|
||||
border-left-color: $navy-600;
|
||||
background: $navy-50;
|
||||
|
||||
.quote { color: $navy-600; }
|
||||
}
|
||||
|
||||
.positive {
|
||||
border-left-color: $green-600;
|
||||
background: $green-50;
|
||||
|
||||
.quote { color: $green-600; }
|
||||
}
|
||||
|
||||
.negative {
|
||||
border-left-color: $red-500;
|
||||
background: $red-50;
|
||||
|
||||
.quote { color: $red-500; }
|
||||
}
|
||||
|
||||
.warning {
|
||||
border-left-color: $gold-500;
|
||||
background: $gold-50;
|
||||
|
||||
.quote { color: $gold-600; }
|
||||
}
|
||||
|
||||
.quote {
|
||||
flex: 0 0 auto;
|
||||
font-family: $font-serif;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: $quote-line-height;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0; // иначе flex-элемент не сужается и длинный текст распирает таблицу
|
||||
}
|
||||
|
||||
.text {
|
||||
white-space: pre-wrap; // сохранить переносы строк из комментария
|
||||
overflow-wrap: anywhere; // длинная ссылка без пробелов не распирает ячейку
|
||||
}
|
||||
|
||||
.clamped {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: $comment-visible-lines;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
margin-top: 2px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: $fs-label;
|
||||
font-weight: 500;
|
||||
color: $navy-700;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: $gold-600;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
// На бумаге кнопку не нажать — печатаем комментарий целиком.
|
||||
@media print {
|
||||
.clamped {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
-webkit-line-clamp: unset;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as React from 'react';
|
||||
import styles from './ApprovalComment.module.scss';
|
||||
import { REQUEST_STATUS } from '../models/statuses';
|
||||
import { useTextOverflow } from './useTextOverflow';
|
||||
|
||||
type CommentTone = 'positive' | 'negative' | 'warning' | 'neutral';
|
||||
|
||||
// Цвет полосы — по решению на шаге: причину отказа или возврата видно с первого
|
||||
// взгляда, ещё до того, как глаз дойдёт до колонки «Статус».
|
||||
const TONE_BY_STATUS: Record<string, CommentTone> = {
|
||||
[REQUEST_STATUS.approved]: 'positive',
|
||||
[REQUEST_STATUS.executed]: 'positive',
|
||||
[REQUEST_STATUS.rejected]: 'negative',
|
||||
[REQUEST_STATUS.returned]: 'warning',
|
||||
};
|
||||
|
||||
const TONE_CLASS: Record<CommentTone, string> = {
|
||||
positive: styles.positive,
|
||||
negative: styles.negative,
|
||||
warning: styles.warning,
|
||||
neutral: styles.neutral,
|
||||
};
|
||||
|
||||
const TEXT = {
|
||||
expand: 'Показать полностью',
|
||||
collapse: 'Свернуть',
|
||||
};
|
||||
|
||||
export interface IApprovalCommentProps {
|
||||
/** Текст комментария к решению на шаге. */
|
||||
text: string;
|
||||
/** Статус строки маршрута (см. REQUEST_STATUS) — от него цвет полосы. */
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Комментарий участника маршрута к своему решению. Длинный текст свёрнут до
|
||||
* нескольких строк (см. $comment-visible-lines), остальное — по кнопке.
|
||||
*/
|
||||
export const ApprovalComment: React.FC<IApprovalCommentProps> = ({ text, status }) => {
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const textRef = React.useRef<HTMLDivElement>(null);
|
||||
const isClamped = useTextOverflow(textRef, text);
|
||||
|
||||
const tone = (status && TONE_BY_STATUS[status]) || 'neutral';
|
||||
// В развёрнутом виде обрезки нет, и замер честно вернёт false, но кнопка
|
||||
// «Свернуть» при этом всё равно нужна.
|
||||
const showToggle = isExpanded || isClamped;
|
||||
|
||||
return (
|
||||
<div className={`${styles.comment} ${TONE_CLASS[tone]}`}>
|
||||
<span className={styles.quote} aria-hidden="true">
|
||||
“
|
||||
</span>
|
||||
<div className={styles.body}>
|
||||
<div ref={textRef} className={isExpanded ? styles.text : `${styles.text} ${styles.clamped}`}>
|
||||
{text}
|
||||
</div>
|
||||
{showToggle && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggle}
|
||||
aria-expanded={isExpanded}
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
>
|
||||
{isExpanded ? TEXT.collapse : TEXT.expand}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,9 +7,16 @@ import { IMatrixRow } from '../models/IMatrixRow';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { UserPicker } from './UserPicker';
|
||||
import { ReplaceRowPanel } from './ReplaceRowPanel';
|
||||
import { ApprovalComment } from './ApprovalComment';
|
||||
import { useFlipRows } from './useFlipRows';
|
||||
import { StepMoveDirection } from '../queuedStepMove';
|
||||
|
||||
/**
|
||||
* Ключ <tr> с комментарием участника. Своя строка — свой ключ: анимация
|
||||
* перестановки (useFlipRows) должна везти комментарий вместе с его участником.
|
||||
*/
|
||||
const commentRowKey = (rowKey: string): string => `${rowKey}:comment`;
|
||||
|
||||
export interface IApprovalMatrixProps {
|
||||
editor: IUseMatrixEditor;
|
||||
search: (query: string) => Promise<ISiteUser[]>;
|
||||
@@ -114,10 +121,16 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
||||
|
||||
// Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку
|
||||
// «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже.
|
||||
const blockByRow: Array<{ index: number; start: number; size: number }> = [];
|
||||
// size — сколько участников в шаге;
|
||||
// span — сколько <tr> он занимает в таблице: у участника с комментарием
|
||||
// под его строкой идёт ещё одна (см. ApprovalComment), и объединённые
|
||||
// ячейки «Шаг» и стрелок обязаны её накрыть, иначе таблица съедет.
|
||||
const blockByRow: Array<{ index: number; start: number; size: number; span: number }> = [];
|
||||
blocks.forEach((b, bi) => {
|
||||
const size = b.end - b.start + 1;
|
||||
const commentCount = rows.slice(b.start, b.end + 1).filter((r) => !!r.comment).length;
|
||||
for (let i = b.start; i <= b.end; i += 1) {
|
||||
blockByRow[i] = { index: bi, start: b.start, size: b.end - b.start + 1 };
|
||||
blockByRow[i] = { index: bi, start: b.start, size, span: size + commentCount };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -139,7 +152,13 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
||||
return;
|
||||
}
|
||||
const b = blockByRow[rowIndex];
|
||||
setActiveKeys(rowKeys.slice(b.start, b.start + b.size));
|
||||
// Подсвечиваем и поднимаем шаг вместе со строками комментариев его участников.
|
||||
const blockKeys: string[] = [];
|
||||
for (let i = b.start; i < b.start + b.size; i += 1) {
|
||||
blockKeys.push(rowKeys[i]);
|
||||
if (rows[i].comment) blockKeys.push(commentRowKey(rowKeys[i]));
|
||||
}
|
||||
setActiveKeys(blockKeys);
|
||||
if (direction === 'up') editor.moveBlockUp(rowIndex);
|
||||
else editor.moveBlockDown(rowIndex);
|
||||
};
|
||||
@@ -190,8 +209,12 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
||||
if (replacingRow && onReplaceRow) onReplaceRow(replacingRow, user);
|
||||
};
|
||||
|
||||
// Число колонок (для colSpan) — на случай будущих нужд не используется: комментарий
|
||||
// выводим внутри ячейки согласующего, чтобы не ломать rowSpan колонки «Шаг».
|
||||
// Комментарий занимает всю ширину между объединёнными колонками «Шаг» и
|
||||
// стрелок: «Согласующий» + «Должность» + те необязательные колонки, что
|
||||
// сейчас показаны. Колонку действий не захватывает — в ней своя пустая ячейка.
|
||||
const optionalColumns = [hasDepartment, hasOrganization, hasStatuses, hasDates, hasSla];
|
||||
const commentColSpan = 2 + optionalColumns.filter(Boolean).length;
|
||||
const hasActionsCol = editable || hasRemovable;
|
||||
|
||||
return (
|
||||
<div className={styles.matrixWrap}>
|
||||
@@ -223,21 +246,24 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
||||
{hasDates && <th>Дата</th>}
|
||||
{hasSla && <th>SLA</th>}
|
||||
{showMoveCol && <th />}
|
||||
{(editable || hasRemovable) && <th />}
|
||||
{hasActionsCol && <th />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => {
|
||||
const block = blockByRow[i];
|
||||
const isBlockStart = block.start === i;
|
||||
const isParallel = block.size > 1;
|
||||
const rowClassNames: string[] = [];
|
||||
if (isParallel) rowClassNames.push(styles.parallelRow);
|
||||
if (r.comment) rowClassNames.push(styles.rowWithComment);
|
||||
const commentClassNames: string[] = [styles.commentRow];
|
||||
if (isParallel) commentClassNames.push(styles.parallelRow);
|
||||
return (
|
||||
<tr
|
||||
key={rowKeys[i]}
|
||||
ref={registerRow(rowKeys[i])}
|
||||
className={block.size > 1 ? styles.parallelRow : undefined}
|
||||
>
|
||||
<React.Fragment key={rowKeys[i]}>
|
||||
<tr ref={registerRow(rowKeys[i])} className={rowClassNames.join(' ') || undefined}>
|
||||
{isBlockStart && (
|
||||
<td className={styles.matrixStepCol} rowSpan={block.size}>
|
||||
<td className={styles.matrixStepCol} rowSpan={block.span}>
|
||||
<span className={styles.stepNo}>{r.order}</span>
|
||||
</td>
|
||||
)}
|
||||
@@ -255,22 +281,13 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
||||
<span className={styles.warningInline}>{r.warning}</span>
|
||||
)}
|
||||
{/* Доп. участник шага, добавленный переназначением — помечаем,
|
||||
кто его добавил. Пусто у обычных строк маршрута. Как и
|
||||
комментарий — inline под именем, чтобы не ломать rowSpan
|
||||
колонки «Шаг» у параллельных шагов. */}
|
||||
кто его добавил. Пусто у обычных строк маршрута. */}
|
||||
{r.addedBy && (
|
||||
<span className={styles.addedByInline}>
|
||||
<span className={styles.addedByLabel}>{addedByLabel}</span>{' '}
|
||||
{r.addedBy}
|
||||
</span>
|
||||
)}
|
||||
{/* Комментарий решения — мелким текстом под именем (не отдельной
|
||||
строкой: иначе ломается rowSpan колонки «Шаг» у параллельных). */}
|
||||
{r.comment && (
|
||||
<span className={styles.commentInline}>
|
||||
<span className={styles.commentLabel}>Комментарий:</span> {r.comment}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td data-label="Должность">{r.position}</td>
|
||||
{hasDepartment && <td data-label="Отдел">{r.department || '—'}</td>}
|
||||
@@ -292,7 +309,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
||||
{/* Перемещение действует на ШАГ целиком (параллельные строки
|
||||
переезжают вместе): одна пара стрелок на весь шаг. */}
|
||||
{showMoveCol && isBlockStart && (
|
||||
<td className={styles.matrixMoveCol} rowSpan={block.size}>
|
||||
<td className={styles.matrixMoveCol} rowSpan={block.span}>
|
||||
{/* В поданной заявке у шагов, которые двигать нельзя совсем
|
||||
(активный, пройденный), стрелок нет — иначе вся колонка
|
||||
из неактивных кнопок только шумит. */}
|
||||
@@ -320,7 +337,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{(editable || hasRemovable) && (
|
||||
{hasActionsCol && (
|
||||
<td className={styles.matrixActions}>
|
||||
{editable && (
|
||||
<button
|
||||
@@ -360,6 +377,19 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
{/* Комментарий — отдельной строкой во всю ширину под участником:
|
||||
в узкой колонке «Согласующий» текст ломался по слову на строку.
|
||||
Ячейки «Шаг» и стрелок её не повторяют — их накрывает rowSpan
|
||||
(см. block.span); колонка действий — пустая ячейка. */}
|
||||
{r.comment && (
|
||||
<tr ref={registerRow(commentRowKey(rowKeys[i]))} className={commentClassNames.join(' ')}>
|
||||
<td colSpan={commentColSpan} className={styles.commentCell}>
|
||||
<ApprovalComment text={r.comment} status={r.status} />
|
||||
</td>
|
||||
{hasActionsCol && <td />}
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
|
||||
@@ -157,27 +157,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Комментарий решения — плашкой под именем согласующего (в той же ячейке,
|
||||
// а не отдельной строкой: иначе ломается rowSpan колонки «Шаг» у параллельных).
|
||||
// Плашка, а не мелкий серый текст: пользователи его не замечали, а там бывает
|
||||
// причина возврата/отклонения.
|
||||
.commentInline {
|
||||
display: block;
|
||||
margin-top: $sp-1;
|
||||
padding: 6px 10px;
|
||||
border-left: 3px solid $navy-600;
|
||||
border-radius: 0 $r-sm $r-sm 0;
|
||||
background: $navy-100;
|
||||
font-size: $fs-sm;
|
||||
color: $gray-900;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap; // сохранить переносы строк из комментария
|
||||
overflow-wrap: anywhere; // длинная ссылка без пробелов не распирает ячейку
|
||||
// Строка участника, под которой идёт его комментарий (см. ApprovalComment):
|
||||
// разделитель между ними убираем, иначе комментарий читается как отдельная
|
||||
// запись маршрута. Ячейки «Шаг» и стрелок не трогаем — они объединены на весь
|
||||
// шаг (rowSpan), и их нижняя граница — это граница всего шага.
|
||||
.rowWithComment > td:not(.matrixStepCol):not(.matrixMoveCol) {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.commentLabel {
|
||||
color: $navy-800;
|
||||
font-weight: 600;
|
||||
// Отступ сверху убран: комментарий прилегает к строке своего участника.
|
||||
// Двойной класс — чтобы перебить padding из `.matrix td`.
|
||||
.matrix .commentCell {
|
||||
padding: 0 8px 10px;
|
||||
}
|
||||
|
||||
// Пометка «доп. согласующий, добавил: …» — под именем согласующего. Золотой
|
||||
@@ -204,7 +195,7 @@
|
||||
|
||||
// Причина, по которой approval-route не назначил человека на шаг (см.
|
||||
// ApprovalMatrix.tsx/r.warning) — красным, как настоящая проблема маршрута
|
||||
// (в отличие от нейтрального addedByInline/commentInline): пустая строка
|
||||
// (в отличие от нейтрального addedByInline): пустая строка
|
||||
// «Согласующий» без этого текста выглядит как баг, а не как «маршрут просит
|
||||
// вмешательства администратора».
|
||||
.warningInline {
|
||||
@@ -378,12 +369,30 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// Комментарий под именем — на всю ширину ячейки (своя строка).
|
||||
.commentInline,
|
||||
.addedByInline {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
// Карточка участника и его комментарий — одна карточка: без линии и
|
||||
// отступа между ними.
|
||||
.matrix tr.rowWithComment {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.matrix tr.commentRow {
|
||||
padding-top: $sp-1;
|
||||
}
|
||||
|
||||
.commentCell > * {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
// Пустая ячейка под колонкой действий на телефоне дала бы лишний отступ.
|
||||
.commentRow > td:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.matrixActions,
|
||||
.matrixMoveCol {
|
||||
justify-content: flex-start;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from 'react';
|
||||
|
||||
/** Расхождение меньше этого — округление субпикселей, а не обрезанный текст. */
|
||||
const OVERFLOW_TOLERANCE_PX = 1;
|
||||
|
||||
/**
|
||||
* Обрезан ли текст элемента по высоте (например, CSS line-clamp).
|
||||
*
|
||||
* Заранее по длине строки это не понять: сколько строк займёт текст, зависит от
|
||||
* ширины колонки, а она меняется вместе с окном и набором колонок таблицы. Поэтому
|
||||
* меряем реальную раскладку и перемеряем при каждом изменении размера элемента.
|
||||
*/
|
||||
export function useTextOverflow<T extends HTMLElement>(ref: React.RefObject<T>, text: string): boolean {
|
||||
const [isOverflowing, setIsOverflowing] = React.useState(false);
|
||||
|
||||
// useLayoutEffect, а не useEffect: кнопка «Показать полностью» должна появиться
|
||||
// в том же кадре, что и текст, иначе строка таблицы дёрнется по высоте.
|
||||
React.useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return undefined;
|
||||
|
||||
const check = (): void => setIsOverflowing(el.scrollHeight - el.clientHeight > OVERFLOW_TOLERANCE_PX);
|
||||
check();
|
||||
|
||||
const observer = new ResizeObserver(check);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [ref, text]);
|
||||
|
||||
return isOverflowing;
|
||||
}
|
||||
+1
@@ -1,5 +1,6 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
// Прочерк вместо пустого слота в режиме чтения.
|
||||
.empty {
|
||||
font-family: $font-sans;
|
||||
font-size: 13.5px;
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from 'react';
|
||||
import { AttachmentList, FileDropzone, FormField } from '../ui';
|
||||
import { IAttachmentEditor } from './useAttachmentEditor';
|
||||
import { attachmentsOfCategory, attachmentsWithoutCategories, categoryPrefix } from './attachmentCategories';
|
||||
import styles from './AttachmentSlotField.module.scss';
|
||||
|
||||
interface Props {
|
||||
/** Подпись поля — что за документ. */
|
||||
label: string;
|
||||
editor: IAttachmentEditor;
|
||||
/** Можно добавлять и удалять файлы (форма подачи / согласующий с полным правом). */
|
||||
editable: boolean;
|
||||
/** Группа новых файлов в editor.pending. */
|
||||
groupKey: string;
|
||||
/** Категория в имени файла (см. attachmentCategories); не задана — «прочие» вложения. */
|
||||
category?: string;
|
||||
/** Для «прочих» (category не задана): категории формы, файлы которых сюда НЕ попадают. */
|
||||
knownCategories?: string[];
|
||||
/** Звёздочка «обязательно» у подписи; саму проверку делает форма. */
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Поле одного подписанного слота вложений: уже прикреплённые файлы + (в правке)
|
||||
* выбор новых. Новые файлы копятся в editor.pending[groupKey] и загружаются
|
||||
* формой при сохранении (editor.uploadPending), с префиксом категории в имени.
|
||||
*/
|
||||
export const AttachmentSlotField: React.FC<Props> = ({
|
||||
label,
|
||||
editor,
|
||||
editable,
|
||||
groupKey,
|
||||
category,
|
||||
knownCategories = [],
|
||||
required
|
||||
}) => {
|
||||
const items = category
|
||||
? attachmentsOfCategory(editor.items, category)
|
||||
: attachmentsWithoutCategories(editor.items, knownCategories);
|
||||
// В списке имена показаны без префикса категории — для удаления возвращаем его.
|
||||
const removeWithPrefix = (name: string): void => editor.remove(category ? categoryPrefix(category) + name : name);
|
||||
|
||||
return (
|
||||
<FormField label={label} required={required} full>
|
||||
{items.length > 0 && <AttachmentList items={items} onRemove={editable ? removeWithPrefix : undefined} />}
|
||||
{items.length === 0 && !editable && <span className={styles.empty}>—</span>}
|
||||
{editable && (
|
||||
<FileDropzone files={editor.pending[groupKey] || []} onChange={(files) => editor.setPending(groupKey, files)} />
|
||||
)}
|
||||
</FormField>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
categoryPrefix,
|
||||
attachmentsOfCategory,
|
||||
attachmentsWithoutCategories
|
||||
} from './attachmentCategories';
|
||||
|
||||
const file = (fileName: string): { fileName: string; url: string } => ({ fileName, url: `/att/${fileName}` });
|
||||
|
||||
describe('attachmentCategories', () => {
|
||||
const items = [file('Резюме — cv.pdf'), file('Удостоверение — id.jpg'), file('старый.docx')];
|
||||
|
||||
it('префикс — подпись и длинное тире с пробелами', () => {
|
||||
expect(categoryPrefix('Резюме')).toBe('Резюме — ');
|
||||
});
|
||||
|
||||
it('файлы категории — с обрезанным префиксом и прежней ссылкой', () => {
|
||||
expect(attachmentsOfCategory(items, 'Резюме')).toEqual([{ fileName: 'cv.pdf', url: '/att/Резюме — cv.pdf' }]);
|
||||
});
|
||||
|
||||
it('без категорий — только файлы, не попавшие ни в одну из известных', () => {
|
||||
expect(attachmentsWithoutCategories(items, ['Резюме', 'Удостоверение'])).toEqual([file('старый.docx')]);
|
||||
});
|
||||
|
||||
it('без известных категорий «прочие» — это все файлы', () => {
|
||||
expect(attachmentsWithoutCategories(items, [])).toEqual(items);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { IAttachmentInfo } from './attachments.service';
|
||||
|
||||
/**
|
||||
* Подписанные вложения («категории»). Вложения SharePoint — плоская коллекция
|
||||
* файлов на элементе списка, подписи у них нет. Поэтому подпись (что за
|
||||
* документ) кодируем ПРЕФИКСОМ в имени файла: «<Подпись> — <исходное имя>».
|
||||
* При загрузке имя файла переписываем, при показе — группируем по префиксу и
|
||||
* префикс срезаем. Вынесено из «Заявки на приём», где появилось первым.
|
||||
*/
|
||||
|
||||
const SEP = ' — ';
|
||||
|
||||
/** Префикс имени файла для категории: «Удостоверение — ». */
|
||||
export function categoryPrefix(category: string): string {
|
||||
return `${category}${SEP}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Копии File с префиксом категории в имени — для загрузки. File.name неизменяемо,
|
||||
* поэтому создаём новый File с тем же содержимым и типом, но другим именем.
|
||||
*/
|
||||
export function prefixFiles(files: File[], category: string): File[] {
|
||||
const prefix = categoryPrefix(category);
|
||||
return files.map((f) => new File([f], prefix + f.name, { type: f.type }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Вложения указанной категории, с ОБРЕЗАННЫМ префиксом в имени (для показа).
|
||||
* url сохраняется как есть; чтобы удалить такой файл, префикс надо вернуть
|
||||
* обратно (categoryPrefix + показанное имя).
|
||||
*/
|
||||
export function attachmentsOfCategory(items: IAttachmentInfo[], category: string): IAttachmentInfo[] {
|
||||
const prefix = categoryPrefix(category);
|
||||
return items
|
||||
.filter((a) => a.fileName.indexOf(prefix) === 0)
|
||||
.map((a) => ({ url: a.url, fileName: a.fileName.slice(prefix.length) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Вложения, не относящиеся ни к одной из известных категорий формы (старые
|
||||
* заявки, «прочие»). Показываем как есть, чтобы файлы не «исчезали» с экрана.
|
||||
*/
|
||||
export function attachmentsWithoutCategories(items: IAttachmentInfo[], categories: string[]): IAttachmentInfo[] {
|
||||
const prefixes = categories.map(categoryPrefix);
|
||||
return items.filter((a) => !prefixes.some((prefix) => a.fileName.indexOf(prefix) === 0));
|
||||
}
|
||||
@@ -4,10 +4,16 @@ import { friendlyErrorMessage } from '../diagnostics';
|
||||
|
||||
/**
|
||||
* Подготовка новых файлов группы к загрузке — напр. префикс категории в имени
|
||||
* (см. forms/RecruitingHire/attachmentCategories). По умолчанию — как есть.
|
||||
* (см. attachmentCategories). По умолчанию — как есть.
|
||||
*/
|
||||
export type PrepareFiles = (groupKey: string, files: File[]) => File[];
|
||||
|
||||
/** Куда грузить: список (Title текущей партиции или url архивной) + id элемента. */
|
||||
export interface IAttachmentTarget {
|
||||
listRef: string;
|
||||
itemId: number;
|
||||
}
|
||||
|
||||
export interface IAttachmentEditor {
|
||||
/** Вложения, уже прикреплённые к заявке. */
|
||||
items: IAttachmentInfo[];
|
||||
@@ -17,8 +23,12 @@ export interface IAttachmentEditor {
|
||||
setPending: (groupKey: string, files: File[]) => void;
|
||||
/** Удалить прикреплённый файл сразу из заявки (по полному имени файла в SP). */
|
||||
remove: (fileName: string) => void;
|
||||
/** Загрузить все выбранные файлы и перечитать список вложений. */
|
||||
uploadPending: (prepare?: PrepareFiles) => Promise<void>;
|
||||
/**
|
||||
* Загрузить все выбранные файлы и перечитать список вложений. target — когда
|
||||
* элемент только что создан и его id ещё не дошёл до хука (сохранение новой
|
||||
* заявки); не задан — грузим в listRef/itemId хука.
|
||||
*/
|
||||
uploadPending: (prepare?: PrepareFiles, target?: IAttachmentTarget) => Promise<void>;
|
||||
/** Ошибка последнего удаления — показать рядом со списком. */
|
||||
error: string | undefined;
|
||||
}
|
||||
@@ -26,14 +36,16 @@ export interface IAttachmentEditor {
|
||||
const keepAsIs: PrepareFiles = (_groupKey, files) => files;
|
||||
|
||||
/**
|
||||
* Правка вложений уже поданной заявки — для согласующего с полным правом
|
||||
* (см. shared/approval/fullEditRights.ts). Удаление — сразу, как в форме
|
||||
* автора; новые файлы копятся и загружаются вместе с сохранением полей
|
||||
* (кнопка «Сохранить» или решение), чтобы не было второй кнопки сохранения.
|
||||
* Правка вложений заявки: удаление — сразу, новые файлы копятся и загружаются
|
||||
* вместе с сохранением полей (чтобы не было второй кнопки сохранения).
|
||||
* Используется в просмотре согласующего с полным правом (см. shared/approval/
|
||||
* fullEditRights.ts) и в формах подачи.
|
||||
*
|
||||
* listRef — Title текущей партиции или url архивной (см. attachments.service).
|
||||
* itemId — undefined, пока новая заявка не сохранена: удалять ещё нечего, а
|
||||
* загрузка идёт с явным target.
|
||||
*/
|
||||
export function useAttachmentEditor(listRef: string, itemId: number): IAttachmentEditor {
|
||||
export function useAttachmentEditor(listRef: string, itemId: number | undefined): IAttachmentEditor {
|
||||
const [items, setItems] = useState<IAttachmentInfo[]>([]);
|
||||
const [pending, setPendingState] = useState<Record<string, File[]>>({});
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
@@ -42,22 +54,27 @@ export function useAttachmentEditor(listRef: string, itemId: number): IAttachmen
|
||||
setPendingState((prev) => ({ ...prev, [groupKey]: files }));
|
||||
|
||||
const remove = (fileName: string): void => {
|
||||
if (itemId === undefined) return;
|
||||
setError(undefined);
|
||||
deleteAttachment(listRef, itemId, fileName)
|
||||
.then(() => setItems((prev) => prev.filter((a) => a.fileName !== fileName)))
|
||||
.catch((e) => setError(`Не удалось удалить файл «${fileName}»: ${friendlyErrorMessage(e)}`));
|
||||
};
|
||||
|
||||
const uploadPending = async (prepare: PrepareFiles = keepAsIs): Promise<void> => {
|
||||
const uploadPending = async (prepare: PrepareFiles = keepAsIs, target?: IAttachmentTarget): Promise<void> => {
|
||||
const files = Object.keys(pending).reduce<File[]>(
|
||||
(acc, groupKey) => acc.concat(prepare(groupKey, pending[groupKey])),
|
||||
[]
|
||||
);
|
||||
if (files.length === 0) return;
|
||||
await uploadAttachments(listRef, itemId, files);
|
||||
const to = target ?? (itemId === undefined ? undefined : { listRef, itemId });
|
||||
if (!to) {
|
||||
throw new Error('Не удалось прикрепить файлы: заявка ещё не сохранена.');
|
||||
}
|
||||
await uploadAttachments(to.listRef, to.itemId, files);
|
||||
// Загруженные убираем из выбора, иначе повторное сохранение продублирует их.
|
||||
setPendingState({});
|
||||
setItems(await getAttachments(listRef, itemId));
|
||||
setItems(await getAttachments(to.listRef, to.itemId));
|
||||
};
|
||||
|
||||
return { items, setItems, pending, setPending, remove, uploadPending, error };
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config';
|
||||
import { IEmployeeFullBaseRecord } from './models/IEmployeeFullBaseRecord';
|
||||
import { IEmployeeFullBasePerson } from './models/IEmployeeFullBasePerson';
|
||||
|
||||
// null из API → '' (см. IEmployeeFullBaseRecord).
|
||||
function textOrEmpty(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function toRecord(r: Record<string, unknown>): IEmployeeFullBaseRecord {
|
||||
return {
|
||||
id: textOrEmpty(r.id),
|
||||
fio: textOrEmpty(r.fio),
|
||||
iin: textOrEmpty(r.iin),
|
||||
code: textOrEmpty(r.code),
|
||||
company: textOrEmpty(r.company),
|
||||
// Должности в базе бывают с хвостовым пробелом («Монтажник ») — textOrEmpty его срезает.
|
||||
position: textOrEmpty(r.position),
|
||||
department: textOrEmpty(r.department),
|
||||
// В API опечатка «admision» — переименовываем на входе, дальше по коду правильное имя.
|
||||
admissionDate: textOrEmpty(r.admisionDate),
|
||||
dismissalDate: textOrEmpty(r.dismissalDate),
|
||||
email: textOrEmpty(r.email)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /Employee/FullBase?Fio=... — работающие сотрудники по части Ф.И.О.
|
||||
*
|
||||
* Без фильтра эндпоинт отдаёт всю базу (тысячи записей, с уволенными), поэтому
|
||||
* ищем на сервере по мере ввода, как PositionPicker — и ничего не кешируем.
|
||||
*
|
||||
* Уволенных отсекаем по dismissalDate на клиенте. Параметр Status у эндпоинта
|
||||
* есть, но его значения не задокументированы — ДОПУЩЕНИЕ, проверить и, если он
|
||||
* фильтрует работающих, перейти на него.
|
||||
*/
|
||||
export async function searchActiveEmployees(
|
||||
context: WebPartContext,
|
||||
fio: string,
|
||||
maxResults: number
|
||||
): Promise<IEmployeeFullBaseRecord[]> {
|
||||
const query = fio.trim();
|
||||
if (!query) return [];
|
||||
|
||||
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
||||
const response: HttpClientResponse = await client.get(
|
||||
`${DICTIONARY_API_BASE_URL}/Employee/FullBase?Fio=${encodeURIComponent(query)}`,
|
||||
AadHttpClient.configurations.v1
|
||||
);
|
||||
if (response.status === 404) return [];
|
||||
if (!response.ok) {
|
||||
throw new Error(`Employee/FullBase: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const body = (await response.json()) as Array<Record<string, unknown>>;
|
||||
return body
|
||||
.map(toRecord)
|
||||
.filter((r) => !r.dismissalDate)
|
||||
.slice(0, maxResults);
|
||||
}
|
||||
|
||||
/** Чей это человек: ИИН, а без него — id записи (тогда запись считается отдельным человеком). */
|
||||
export function personKey(record: IEmployeeFullBaseRecord): string {
|
||||
return record.iin || record.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Уникальный ключ места работы — для value в выпадающем списке. Не только id:
|
||||
* у совместителя на каждое место работы может прийти один и тот же id человека.
|
||||
*/
|
||||
export function workplaceKey(record: IEmployeeFullBaseRecord): string {
|
||||
return `${record.id}|${record.company}|${record.position}`;
|
||||
}
|
||||
|
||||
/** Человек из одной записи — когда остальные его места работы неизвестны (напр. просмотр заявки). */
|
||||
export function personFromWorkplace(record: IEmployeeFullBaseRecord): IEmployeeFullBasePerson {
|
||||
return { key: personKey(record), fio: record.fio, iin: record.iin, email: record.email, workplaces: [record] };
|
||||
}
|
||||
|
||||
/** Записи FullBase → люди (по ИИН), порядок — по первому появлению человека в ответе. */
|
||||
export function groupEmployeesByPerson(records: IEmployeeFullBaseRecord[]): IEmployeeFullBasePerson[] {
|
||||
const people: IEmployeeFullBasePerson[] = [];
|
||||
records.forEach((record) => {
|
||||
const person = people.find((p) => p.key === personKey(record));
|
||||
if (!person) {
|
||||
people.push(personFromWorkplace(record));
|
||||
return;
|
||||
}
|
||||
person.workplaces.push(record);
|
||||
if (!person.email) person.email = record.email;
|
||||
});
|
||||
return people;
|
||||
}
|
||||
@@ -19,6 +19,17 @@ export {
|
||||
employeeCompanies
|
||||
} from './employeeOrganizations.service';
|
||||
export type { IEmployeeOrganization } from './models/IEmployeeOrganization';
|
||||
export {
|
||||
searchActiveEmployees,
|
||||
groupEmployeesByPerson,
|
||||
personKey,
|
||||
personFromWorkplace,
|
||||
workplaceKey
|
||||
} from './employeeFullBase.service';
|
||||
export type { IEmployeeFullBaseRecord } from './models/IEmployeeFullBaseRecord';
|
||||
export type { IEmployeeFullBasePerson } from './models/IEmployeeFullBasePerson';
|
||||
export { EmployeeFullBasePicker } from './ui/EmployeeFullBasePicker';
|
||||
export type { IEmployeeFullBasePickerProps } from './ui/EmployeeFullBasePicker';
|
||||
export { getNomenclature } from './nomenclature.service';
|
||||
export type { INomenclatureRow } from './models/INomenclatureRow';
|
||||
export { searchPositions } from './positions.service';
|
||||
@@ -33,5 +44,9 @@ export {
|
||||
} from './employeeLookup.service';
|
||||
export { CityPicker } from './ui/CityPicker';
|
||||
export type { ICityPickerProps, ICityValue } from './ui/CityPicker';
|
||||
export { formatEmployeeOrganizationsWithDepartment, parseOrganizationLine } from './organizationLines';
|
||||
export {
|
||||
formatEmployeeOrganizationsWithDepartment,
|
||||
formatOrganizationLine,
|
||||
parseOrganizationLine
|
||||
} from './organizationLines';
|
||||
export type { IOrganizationLine } from './organizationLines';
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { IEmployeeFullBaseRecord } from './IEmployeeFullBaseRecord';
|
||||
|
||||
/**
|
||||
* Человек из Employee/FullBase — все его записи (места работы), собранные по ИИН.
|
||||
* Пикер выбирает человека, а конкретное место работы форма выбирает отдельно
|
||||
* (у совместителя их несколько) — как «Командирующая организация» в командировке.
|
||||
*/
|
||||
export interface IEmployeeFullBasePerson {
|
||||
/** Ключ группировки: ИИН, а если его нет — id записи (см. personKey). */
|
||||
key: string;
|
||||
fio: string;
|
||||
iin: string;
|
||||
/** Первый непустой e-mail среди мест работы; '' — нет учётной записи. */
|
||||
email: string;
|
||||
/** Места работы, минимум одно. */
|
||||
workplaces: IEmployeeFullBaseRecord[];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Одна запись из GET /Employee/FullBase — полная база сотрудников из кадровой
|
||||
* системы, ВКЛЮЧАЯ уволенных и рабочих без учётной записи M365.
|
||||
*
|
||||
* Запись = одно место работы, а не человек: совместитель приходит несколькими
|
||||
* записями с одним iin и разными company/position/department.
|
||||
*
|
||||
* В отличие от Employee/ByEmail (поля с большой буквы), этот эндпоинт отдаёт
|
||||
* camelCase — так в ответе API. null из API приводится к '' (как в
|
||||
* IEmployeeOrganization): «нет значения» везде — пустая строка.
|
||||
*/
|
||||
export interface IEmployeeFullBaseRecord {
|
||||
id: string;
|
||||
/** «Фамилия Имя Отчество». */
|
||||
fio: string;
|
||||
/** ИИН, 12 цифр. */
|
||||
iin: string;
|
||||
/** Табельный номер в 1С (напр. «ЗК-0073429»). */
|
||||
code: string;
|
||||
company: string;
|
||||
position: string;
|
||||
department: string;
|
||||
/** Дата приёма, «YYYY-MM-DDT00:00:00»; '' — не указана. */
|
||||
admissionDate: string;
|
||||
/** Дата увольнения; '' — работает. */
|
||||
dismissalDate: string;
|
||||
/** Рабочий e-mail; '' — у сотрудника нет учётной записи (рабочие на объектах). */
|
||||
email: string;
|
||||
}
|
||||
@@ -8,12 +8,17 @@ import { IEmployeeOrganization } from './models/IEmployeeOrganization';
|
||||
* формат прежний, как у formatEmployeeOrganizations.
|
||||
*/
|
||||
export function formatEmployeeOrganizationsWithDepartment(records: IEmployeeOrganization[]): string {
|
||||
return records
|
||||
.map((r) => {
|
||||
if (r.section) return [r.company, r.position, r.section].join(' — ');
|
||||
return r.position ? `${r.company} — ${r.position}` : r.company;
|
||||
})
|
||||
.join('\n');
|
||||
return records.map((r) => formatOrganizationLine(r.company, r.position, r.section)).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Одна строка «Компания — должность[ — подразделение]» — тот же формат, что у
|
||||
* formatEmployeeOrganizationsWithDepartment, для источников кроме Employee/ByEmail
|
||||
* (напр. запись Employee/FullBase).
|
||||
*/
|
||||
export function formatOrganizationLine(company: string, position: string, department: string): string {
|
||||
if (department) return [company, position, department].join(' — ');
|
||||
return position ? `${company} — ${position}` : company;
|
||||
}
|
||||
|
||||
export interface IOrganizationLine {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
// Поле и выпадающий список — в той же стилистике, что PositionPicker.
|
||||
|
||||
.wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
// справа место под крестик-очистку
|
||||
padding: 11px 36px 11px 13px;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $r-sm;
|
||||
background: $white;
|
||||
font-family: $font-sans;
|
||||
font-size: 13.5px;
|
||||
color: $gray-900;
|
||||
transition: .18s;
|
||||
|
||||
@include input-focus-gold;
|
||||
@include readonly-field(':read-only');
|
||||
|
||||
&::placeholder {
|
||||
color: $gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
.clear {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 8px;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: $r-sm;
|
||||
background: transparent;
|
||||
color: $gray-400;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: .15s;
|
||||
|
||||
&:hover {
|
||||
background: $gray-100;
|
||||
color: $gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: $sp-1;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-sm;
|
||||
box-shadow: $sh-2;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
font-family: $font-sans;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: $navy-50;
|
||||
}
|
||||
}
|
||||
|
||||
.itemName {
|
||||
font-size: $fs-sm;
|
||||
color: $gray-900;
|
||||
}
|
||||
|
||||
.itemMeta {
|
||||
font-size: 12px;
|
||||
color: $gray-500;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.status,
|
||||
.error {
|
||||
padding: 9px 12px;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-sm;
|
||||
}
|
||||
|
||||
.status {
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: $red-500;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import * as React from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './EmployeeFullBasePicker.module.scss';
|
||||
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||
import { searchActiveEmployees, groupEmployeesByPerson } from '../employeeFullBase.service';
|
||||
import { IEmployeeFullBasePerson } from '../models/IEmployeeFullBasePerson';
|
||||
|
||||
export interface IEmployeeFullBasePickerProps {
|
||||
context: WebPartContext;
|
||||
id?: string;
|
||||
/** Выбранный человек (со всеми местами работы); undefined — не выбран. */
|
||||
value: IEmployeeFullBasePerson | undefined;
|
||||
onChange: (value: IEmployeeFullBasePerson | undefined) => void;
|
||||
placeholder?: string;
|
||||
/** Только чтение (режим согласующего). */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 300;
|
||||
// Записей у совместителя несколько на человека — берём с запасом, людей в списке потом меньше.
|
||||
const MAX_RECORDS = 60;
|
||||
// По 1–2 буквам совпадений тысячи — запрос тяжёлый, а выбрать из такого списка всё равно нельзя.
|
||||
const MIN_QUERY_LENGTH = 3;
|
||||
|
||||
const TEXT_SEARCHING = 'Поиск…';
|
||||
const TEXT_TYPE_MORE = `Введите не меньше ${MIN_QUERY_LENGTH} букв Ф.И.О.`;
|
||||
const TEXT_NOT_FOUND = 'Работающих сотрудников с таким Ф.И.О. не найдено';
|
||||
const TEXT_LOAD_ERROR = 'Не удалось загрузить список сотрудников. Попробуйте ещё раз.';
|
||||
|
||||
/** Компании человека через запятую — чтобы различать тёзок. */
|
||||
function describePerson(person: IEmployeeFullBasePerson): string {
|
||||
return person.workplaces.map((w) => w.company).filter(Boolean).join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбор сотрудника из полной кадровой базы (GET /Employee/FullBase) — в отличие
|
||||
* от PeoplePicker находит и тех, у кого нет учётной записи M365.
|
||||
*
|
||||
* Значение — человек целиком (все места работы, собранные по ИИН), а не текст:
|
||||
* ручной ввод не принимается, иначе у заявки не будет ИИН, компании и e-mail.
|
||||
* Начали править текст после выбора — выбор сбрасывается (onChange(undefined)).
|
||||
* Конкретное место работы (организацию) форма выбирает отдельным полем.
|
||||
*/
|
||||
export const EmployeeFullBasePicker: React.FC<IEmployeeFullBasePickerProps> = ({
|
||||
context,
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
readOnly
|
||||
}) => {
|
||||
const contextReadOnly = useFormReadOnly();
|
||||
const effectiveReadOnly = readOnly ?? contextReadOnly;
|
||||
|
||||
const [query, setQuery] = React.useState<string>('');
|
||||
const [suggestions, setSuggestions] = React.useState<IEmployeeFullBasePerson[]>([]);
|
||||
const [open, setOpen] = React.useState<boolean>(false);
|
||||
const [loading, setLoading] = React.useState<boolean>(false);
|
||||
const [loadError, setLoadError] = React.useState<boolean>(false);
|
||||
// Счётчик запросов — ответ на устаревший запрос не перезаписывает более свежий (как в PositionPicker).
|
||||
const requestIdRef = React.useRef(0);
|
||||
|
||||
const trimmedQuery = query.trim();
|
||||
const isQueryTooShort = trimmedQuery.length < MIN_QUERY_LENGTH;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isQueryTooShort) {
|
||||
setSuggestions([]);
|
||||
setLoading(false);
|
||||
setLoadError(false);
|
||||
return;
|
||||
}
|
||||
const requestId = ++requestIdRef.current;
|
||||
setLoading(true);
|
||||
const timer = setTimeout(() => {
|
||||
searchActiveEmployees(context, trimmedQuery, MAX_RECORDS)
|
||||
.then((records) => {
|
||||
if (requestIdRef.current !== requestId) return;
|
||||
setSuggestions(groupEmployeesByPerson(records));
|
||||
setLoadError(false);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestIdRef.current !== requestId) return;
|
||||
setSuggestions([]);
|
||||
setLoadError(true);
|
||||
setLoading(false);
|
||||
});
|
||||
}, DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [context, trimmedQuery, isQueryTooShort]);
|
||||
|
||||
const handleInput = (text: string): void => {
|
||||
if (value) onChange(undefined);
|
||||
setQuery(text);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handlePick = (person: IEmployeeFullBasePerson): void => {
|
||||
onChange(person);
|
||||
setQuery('');
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleClear = (): void => {
|
||||
onChange(undefined);
|
||||
setQuery('');
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const statusText = getStatusText(loading, loadError, isQueryTooShort, suggestions.length);
|
||||
const isListShown = !effectiveReadOnly && open && !value && trimmedQuery.length > 0;
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<input
|
||||
id={id}
|
||||
className={styles.input}
|
||||
value={value ? value.fio : query}
|
||||
placeholder={placeholder}
|
||||
readOnly={effectiveReadOnly}
|
||||
autoComplete="off"
|
||||
onChange={(e) => handleInput(e.target.value)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
/>
|
||||
{/* preventDefault на mousedown — чтобы клик не «съел» фокус до onClick (как в PositionPicker). */}
|
||||
{!effectiveReadOnly && (value || query) && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.clear}
|
||||
aria-label="Очистить"
|
||||
title="Очистить"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={handleClear}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
{isListShown && (
|
||||
<div className={styles.list}>
|
||||
{statusText && <div className={loadError ? styles.error : styles.status}>{statusText}</div>}
|
||||
{!statusText &&
|
||||
suggestions.map((person) => (
|
||||
<button
|
||||
key={person.key}
|
||||
type="button"
|
||||
className={styles.item}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => handlePick(person)}
|
||||
>
|
||||
<span className={styles.itemName}>{person.fio}</span>
|
||||
<span className={styles.itemMeta}>{describePerson(person)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Текст служебной строки списка; '' — показать сами подсказки. */
|
||||
function getStatusText(loading: boolean, loadError: boolean, isQueryTooShort: boolean, found: number): string {
|
||||
if (isQueryTooShort) return TEXT_TYPE_MORE;
|
||||
if (loading) return TEXT_SEARCHING;
|
||||
if (loadError) return TEXT_LOAD_ERROR;
|
||||
if (found === 0) return TEXT_NOT_FOUND;
|
||||
return '';
|
||||
}
|
||||
@@ -34,6 +34,7 @@ const LIST_TITLE_BASE: Record<RequestTypeId, string> = {
|
||||
'hr-unpaid-leave': 'Отпуск без содержания',
|
||||
'hr-skud-explanation': 'Объяснительная по СКУД',
|
||||
'hr-business-trip': 'Заявка на командировку',
|
||||
'hr-contract-change': 'Изменение условий трудового договора',
|
||||
'it-1c-ticket': 'Заявка в отдел 1С',
|
||||
'it-workplace-setup': 'Организация рабочего места',
|
||||
'recruiting-hire': 'Прием на работу',
|
||||
|
||||
Reference in New Issue
Block a user