Merge branch 'main' of http://git.tnsu.kz/timur.gorodetskiy/request-portal
Tests / frontend-test (push) Successful in 2m2s

This commit is contained in:
Тимур Городецкий
2026-10-06 17:23:25 +05:00
85 changed files with 2152 additions and 171 deletions
@@ -13,6 +13,7 @@ import {
useRequestSubmit
} from '../../shared/ui';
import {
composeRemoveRowRule,
ApprovalMatrix,
ApprovalHistory,
StatusBadge,
@@ -27,6 +28,7 @@ import {
APPROVAL_PHASE,
unassignedStepMessage
} from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
@@ -327,7 +329,11 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
<RequestSection {...sectionProps} />
</FormModeProvider>
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
<ApprovalMatrix
editor={matrix}
search={(q) => approval.searchUsers(q)}
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
/>
{/* Блок исполнителя показываем, только когда по маршруту реально есть
исполнители (условие «IsManager» и т.п. могло не назначить ни одного). */}
@@ -36,7 +36,7 @@ RequestTypeId» в `docs/forms.md`). Тип уже зарегистрирова
- **Сумма обучения** — `MoneyInput` (разделитель тысяч в UI, в списке — «сырое»
число), колонка `TrainingAmount` (Number, 2 знака), поле необязательное.
- **Дата расторжения договора** — обычный `input type=date`, без вычислений
(в отличие от `EndDate` в «Заявке на отпуск», которая считается).
(в отличие от даты окончания отпуска, которой нет совсем).
- **Заголовок элемента** (`Title`): `Обходной лист — {ФИО} — дд.мм.гггг`.
## Связь с заявлением на увольнение
@@ -13,7 +13,9 @@ import {
useRequestSubmit
} from '../../shared/ui';
import {
composeRemoveRowRule,
ApprovalMatrix,
ExecutorsMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
@@ -24,8 +26,13 @@ import {
REQUEST_STATUS,
isEditableStatus,
IApproverToWrite,
unassignedStepMessage
unassignedStepMessage,
useFormExecutors,
approvalPhaseRows,
executorsToWrite,
initialSubmitStatus
} from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
@@ -169,7 +176,7 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
const loadMatrix = useCallback(
() =>
editId
? approval.getMatrixForRequest(editId)
? approval.getMatrixForRequest(editId).then(approvalPhaseRows)
: getPlannedMatrix(context, requestTypeId as number, data.SiteChiefEmail, approval, 'Approval', null, undefined, projectId),
[context, approval, editId, data.SiteChiefEmail, requestTypeId, projectId]
);
@@ -181,6 +188,18 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
(login) => approval.getJobTitle(login)
);
// Исполнители — из маршрута админки (заявитель их не правит); те же параметры
// резолва, что и у согласующих.
const executors = useFormExecutors({
readOnly,
context,
approval,
requestTypeId,
userEmail: data.SiteChiefEmail,
editId,
projectId
});
const hasItems = data.Items.some((i) => i.mixClass.trim().length > 0 && i.volume.trim().length > 0);
const isValid = !!(data.RequestDate && data.SiteChiefEmail && data.Project.trim() && hasItems);
const validate = (): string | undefined => {
@@ -222,6 +241,7 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
rtId,
data,
toApprovers(matrix.rows),
executorsToWrite(executors.rows),
status,
writeListId
);
@@ -236,7 +256,7 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
// Имена списков — той же партиции, куда фактически записана заявка (writeListId),
// не текущей: поток ищет строку маршрута именно в НЕЙ.
if (status === REQUEST_STATUS.onApproval) {
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),
@@ -258,11 +278,21 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
);
if (unassignedError) return unassignedError;
const unassignedExecutorError = unassignedStepMessage(
executors.rows,
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
);
if (unassignedExecutorError) return unassignedExecutorError;
return undefined;
};
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
// Без исполнителей — прежний статус «На согласовании»; с ними — как у рефералки
// (initialSubmitStatus учитывает маршрут без согласующих).
const submitStatus = executors.rows.length > 0
? initialSubmitStatus(matrix.rows.length, executors.rows.length)
: REQUEST_STATUS.onApproval;
const handleSubmit = (): Promise<void> => submit.run('submit', save(submitStatus), validateSubmit);
const sectionProps = {
data,
@@ -323,11 +353,14 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
<ApprovalMatrix
editor={matrix}
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
search={(q) => approval.searchUsers(q)}
waiting={!matrixReady}
waitingText="Укажите начальника участка и проект, чтобы построить маршрут согласования."
/>
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
@@ -8,10 +8,13 @@ import {
DecisionPanel,
StatusBadge,
ApprovalMatrix,
ExecutorsMatrix,
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
useRequestTypeId
useRequestTypeId,
useViewExecutors,
approvalPhaseRows
} from '../../shared/approval';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
@@ -71,10 +74,11 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
const [decisionSeq, setDecisionSeq] = useState<number>(0);
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]);
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;
@@ -153,6 +157,7 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
</FormCard>
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
</div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
@@ -47,7 +47,7 @@ export type ISaveResult = ISaveItemResult;
/**
* Сохранить заявку на бетон: содержимое + маршрут согласования.
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей.
* Исполнители (фаза «Исполнение») приходят из маршрута админки и пишутся тем же writeRoute.
* Возвращает Id заявки (+ код при создании).
*
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadConcreteRequest). Имеет смысл
@@ -59,12 +59,13 @@ export async function saveConcreteRequest(
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(concreteRequestApprovalService(listId), result.id, approvers, [], status);
await writeRoute(concreteRequestApprovalService(listId), result.id, approvers, executors, status);
return result;
}
@@ -13,7 +13,9 @@ import {
useRequestSubmit
} from '../../shared/ui';
import {
composeRemoveRowRule,
ApprovalMatrix,
ExecutorsMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
@@ -24,8 +26,13 @@ import {
isEditableStatus,
IApproverToWrite,
unassignedStepMessage,
resolveWriteListId
resolveWriteListId,
approvalPhaseRows,
executorsToWrite,
initialSubmitStatus,
useFormExecutors
} from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types';
import { getEmployeeOrganizations, getCompanyDirectorEmail, IEmployeeOrganization } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
@@ -157,7 +164,7 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
const loadMatrix = useCallback(
() =>
editId
? approval.getMatrixForRequest(editId)
? approval.getMatrixForRequest(editId).then(approvalPhaseRows)
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval', null, [
{ fieldName: 'Position', value: primaryPosition(data.Organization) }
]),
@@ -171,6 +178,18 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
(login) => approval.getJobTitle(login)
);
// Исполнители самого заявления (фаза «Исполнение» маршрута из админки) — по
// тому же сотруднику и тем же fieldValues, что и согласующие выше.
const executors = useFormExecutors({
readOnly,
context,
approval,
requestTypeId,
userEmail: data.Employee,
editId,
fieldValues: [{ fieldName: 'Position', value: primaryPosition(data.Organization) }]
});
// Убрать компанию из заявления (совместитель, реально не увольняется из
// ВСЕХ) — см. OrganizationsTable. Снимает компанию из data.Organization И, по
// возможности, соответствующего согласующего (директора/должностного
@@ -243,6 +262,7 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
rtId,
data,
toApprovers(matrix.rows),
executorsToWrite(executors.rows),
status,
writeListId
);
@@ -257,7 +277,7 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
// Имена списков — той же партиции, куда фактически записана заявка (writeListId),
// не текущей: поток ищет строку маршрута именно в НЕЙ.
if (status === REQUEST_STATUS.onApproval) {
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),
@@ -279,11 +299,27 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
);
if (unassignedError) return unassignedError;
const unassignedExecutorError = unassignedStepMessage(
executors.rows,
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
);
if (unassignedExecutorError) return unassignedExecutorError;
return undefined;
};
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
// Исполнители в маршруте есть → статус стартует с учётом фаз (initialSubmitStatus:
// без согласующих сразу «Выполнение»); иначе прежний «На согласовании».
const handleSubmit = (): Promise<void> =>
submit.run(
'submit',
save(
executors.rows.length > 0
? initialSubmitStatus(matrix.rows.length, executors.rows.length)
: REQUEST_STATUS.onApproval
),
validateSubmit
);
const sectionProps = {
data,
@@ -340,7 +376,13 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
<RequestSection {...sectionProps} />
</FormModeProvider>
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
<ApprovalMatrix
editor={matrix}
search={(q) => approval.searchUsers(q)}
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
/>
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили, — поэтому история и в форме, а не только в просмотре.
@@ -8,11 +8,14 @@ import {
DecisionPanel,
StatusBadge,
ApprovalMatrix,
ExecutorsMatrix,
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
useRequestTypeId,
REQUEST_STATUS
REQUEST_STATUS,
approvalPhaseRows,
useViewExecutors
} from '../../shared/approval';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import { getManagerEmail } from '../../shared/dictionary';
@@ -87,10 +90,12 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
const [decisionSeq, setDecisionSeq] = useState<number>(0);
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]);
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;
@@ -282,6 +287,8 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
</FormCard>
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
</div>
{/* История согласования: маршрут пересохраняется патчем (см.
@@ -96,7 +96,9 @@ export type ISaveResult = ISaveItemResult;
/**
* Сохранить заявление на увольнение: содержимое + маршрут согласования.
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей.
* executors — исполнители самого заявления (фаза «Исполнение» маршрута из
* админки; пусто, если в маршруте их нет). Это не маршрут связанной заявки
* Clearance (см. createClearance.ts).
* Возвращает Id заявки (+ код при создании).
*
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadDismissalRequest). Имеет смысл
@@ -109,6 +111,7 @@ export async function saveDismissalRequest(
requestTypeId: number,
data: IFormData,
approvers: IApproverToWrite[],
executors: IApproverToWrite[],
status: string,
listId?: string
): Promise<ISaveResult> {
@@ -116,7 +119,7 @@ export async function saveDismissalRequest(
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee);
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
await writeRoute(dismissalApprovalService(listId), result.id, approvers, [], status);
await writeRoute(dismissalApprovalService(listId), result.id, approvers, executors, status);
return result;
}
@@ -13,10 +13,16 @@ import {
useRequestSubmit
} from '../../shared/ui';
import {
composeRemoveRowRule,
ApprovalMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
useFormExecutors,
ExecutorsMatrix,
approvalPhaseRows,
executorsToWrite,
initialSubmitStatus,
getPlannedMatrix,
resolveWriteListId,
notifyActiveStep,
@@ -26,6 +32,7 @@ import {
IApproverToWrite,
unassignedStepMessage
} from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
@@ -153,7 +160,7 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
const loadMatrix = useCallback(
() =>
editId
? approval.getMatrixForRequest(editId)
? approval.getMatrixForRequest(editId).then(approvalPhaseRows)
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval'),
[context, approval, editId, data.Employee, requestTypeId]
);
@@ -165,6 +172,17 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
(login) => approval.getJobTitle(login)
);
// Исполнители — по тому же сотруднику, что и маршрут согласования. Только чтение:
// их назначает маршрут из админки, не заявитель.
const executors = useFormExecutors({
readOnly,
context,
approval,
requestTypeId,
userEmail: data.Employee,
editId
});
const isValid = !!(
data.Employee &&
data.Position.trim() &&
@@ -211,6 +229,7 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
rtId,
data,
toApprovers(matrix.rows),
executorsToWrite(executors.rows),
status,
writeListId
);
@@ -225,7 +244,7 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
// Имена списков — той же партиции, куда фактически записана заявка (writeListId),
// не текущей: поток ищет строку маршрута именно в НЕЙ.
if (status === REQUEST_STATUS.onApproval) {
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),
@@ -247,11 +266,27 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
);
if (unassignedError) return unassignedError;
const unassignedExecutorError = unassignedStepMessage(
executors.rows,
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
);
if (unassignedExecutorError) return unassignedExecutorError;
return undefined;
};
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
// Есть исполнители — initialSubmitStatus учитывает маршрут без согласующих
// (сразу «Выполнение»); иначе прежний статус «На согласовании».
const handleSubmit = (): Promise<void> =>
submit.run(
'submit',
save(
executors.rows.length > 0
? initialSubmitStatus(matrix.rows.length, executors.rows.length)
: REQUEST_STATUS.onApproval
),
validateSubmit
);
const sectionProps = { data, set, context, onEmployeeChange: handleEmployeeChange };
@@ -302,7 +337,13 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
<RequestSection {...sectionProps} />
</FormModeProvider>
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
<ApprovalMatrix
editor={matrix}
search={(q) => approval.searchUsers(q)}
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
/>
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
@@ -10,6 +10,9 @@ import {
ApprovalMatrix,
ApprovalHistory,
useMatrixEditor,
useViewExecutors,
ExecutorsMatrix,
approvalPhaseRows,
notifyActiveStep,
useRequestTypeId
} from '../../shared/approval';
@@ -70,10 +73,11 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
const [decisionSeq, setDecisionSeq] = useState<number>(0);
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]);
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;
@@ -150,6 +154,7 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
</FormCard>
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
</div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
@@ -43,7 +43,8 @@ export type ISaveResult = ISaveItemResult;
/**
* Сохранить заявку на перемещение: содержимое + маршрут согласования.
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей.
* executors — исполнители из маршрута (фаза «Исполнение»); пустой список, если в
* маршруте их нет.
* Возвращает Id заявки (+ код при создании).
*
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadEmployeeTransferRequest). Имеет
@@ -55,6 +56,7 @@ export async function saveEmployeeTransferRequest(
requestTypeId: number,
data: IFormData,
approvers: IApproverToWrite[],
executors: IApproverToWrite[],
status: string,
listId?: string
): Promise<ISaveResult> {
@@ -62,6 +64,6 @@ export async function saveEmployeeTransferRequest(
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee);
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
await writeRoute(employeeTransferApprovalService(listId), result.id, approvers, [], status);
await writeRoute(employeeTransferApprovalService(listId), result.id, approvers, executors, status);
return result;
}
@@ -26,6 +26,7 @@ import {
ApprovalHistory,
StatusBadge,
useMatrixEditor,
canEditExecutors,
getPlannedMatrix,
resolveWriteListId,
notifyActiveStep,
@@ -206,7 +207,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
const executors = useMatrixEditor(
loadExecutors,
`${userEmail}-${editId ?? 'new'}-${projectId}-${companiesFieldValue}`,
false,
canEditExecutors(context, readOnly),
matrixReady,
(login) => approval.getJobTitle(login),
resolveApproverOrgInfo(context)
@@ -448,12 +449,14 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
canRemoveRow={canRemoveApprover ? () => true : undefined}
/>
{!executors.loading && executors.rows.length > 0 && (
{!executors.loading && (executors.editable || executors.rows.length > 0) && (
<ApprovalMatrix
editor={executors}
search={(q) => approval.searchUsers(q)}
title="Исполнение (после согласования)"
roleLabel="Исполнитель"
canRemoveRow={executors.editable ? () => true : undefined}
pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…"
emptyText="Исполнители не заданы по маршруту."
/>
)}
@@ -26,6 +26,7 @@ import {
ApprovalHistory,
StatusBadge,
useMatrixEditor,
canEditExecutors,
getPlannedMatrix,
resolveWriteListId,
notifyActiveStep,
@@ -236,7 +237,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
const executors = useMatrixEditor(
loadExecutors,
`${userEmail}-${editId ?? 'new'}-${projectId}-${companiesFieldValue}`,
false,
canEditExecutors(context, readOnly),
matrixReady,
(login) => approval.getJobTitle(login),
resolveApproverOrgInfo(context)
@@ -439,12 +440,14 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
canRemoveRow={canRemoveApprover ? () => true : undefined}
/>
{!executors.loading && executors.rows.length > 0 && (
{!executors.loading && (executors.editable || executors.rows.length > 0) && (
<ApprovalMatrix
editor={executors}
search={(q) => approval.searchUsers(q)}
title="Исполнение (после согласования)"
roleLabel="Исполнитель"
canRemoveRow={executors.editable ? () => true : undefined}
pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…"
emptyText="Исполнители не заданы по маршруту."
/>
)}
@@ -21,6 +21,7 @@ import {
ApprovalHistory,
StatusBadge,
useMatrixEditor,
canEditExecutors,
getPlannedMatrix,
notifyActiveStep,
useRequestTypeId,
@@ -241,7 +242,7 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
const executors = useMatrixEditor(
loadExecutors,
`${data.Referrer}-${editId ?? 'new'}`,
false,
canEditExecutors(context, readOnly),
!!editId || (!!data.Referrer && !!requestTypeId),
(login) => approval.getJobTitle(login),
resolveApproverOrgInfo(context)
@@ -470,12 +471,14 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
canRemoveRow={canRemoveApprover ? () => true : undefined}
/>
{!executors.loading && executors.rows.length > 0 && (
{!executors.loading && (executors.editable || executors.rows.length > 0) && (
<ApprovalMatrix
editor={executors}
search={(q) => approval.searchUsers(q)}
title={noApprovalPhase ? 'Исполнение' : 'Исполнение (после согласования)'}
roleLabel="Исполнитель"
canRemoveRow={executors.editable ? () => true : undefined}
pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…"
emptyText="Исполнители не заданы по маршруту."
/>
)}
@@ -13,6 +13,7 @@ import {
useRequestSubmit
} from '../../shared/ui';
import {
composeRemoveRowRule,
ApprovalMatrix,
ApprovalHistory,
StatusBadge,
@@ -24,7 +25,12 @@ import {
isEditableStatus,
IApproverToWrite,
unassignedStepMessage,
resolveWriteListId
resolveWriteListId,
useFormExecutors,
ExecutorsMatrix,
approvalPhaseRows,
executorsToWrite,
initialSubmitStatus
} from '../../shared/approval';
import {
uploadAttachments,
@@ -181,13 +187,24 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
const loadMatrix = useCallback(
() =>
editId
? approval.getMatrixForRequest(editId)
? approval.getMatrixForRequest(editId).then(approvalPhaseRows)
: getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, undefined, projectId),
[context, userEmail, approval, editId, projectId, requestTypeId]
);
const matrix = useMatrixEditor(loadMatrix, `${userEmail}-${editId ?? 'new'}-${projectId}`, !readOnly, matrixReady, (login) =>
approval.getJobTitle(login)
);
// Исполнители резолвятся тем же маршрутом и с тем же проектом, что и согласующие:
// пока проект не выбран, e-mail пустой — useFormExecutors не грузит маршрут (404).
const executors = useFormExecutors({
readOnly,
context,
approval,
requestTypeId,
userEmail: matrixReady ? userEmail : '',
editId,
projectId
});
const hasCosts = hasAnyCostItem(data.Costs);
const isValid =
@@ -273,7 +290,7 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
}
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
const { id, code } = await saveServiceRequest(requestId, rtId, data, toApprovers(matrix.rows), status, writeListId);
const { id, code } = await saveServiceRequest(requestId, rtId, data, toApprovers(matrix.rows), executorsToWrite(executors.rows), status, writeListId);
setRequestId(id);
setRequestStatus(status);
if (code) {
@@ -294,7 +311,7 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
if (onSaved) {
onSaved();
}
if (status === REQUEST_STATUS.onApproval) {
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),
@@ -314,11 +331,26 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
);
if (unassignedError) return unassignedError;
const unassignedExecutorError = unassignedStepMessage(
executors.rows,
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
);
if (unassignedExecutorError) return unassignedExecutorError;
return undefined;
};
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
const handleSubmit = (): Promise<void> => submit.run(
'submit',
// Есть исполнители — initialSubmitStatus сам выбирает стартовый статус (согласующих
// может не быть вовсе), иначе прежнее «На согласовании».
save(
executors.rows.length > 0
? initialSubmitStatus(matrix.rows.length, executors.rows.length)
: REQUEST_STATUS.onApproval
),
validateSubmit
);
const sectionProps = {
data,
@@ -384,11 +416,14 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
<ApprovalMatrix
editor={matrix}
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
search={(q) => approval.searchUsers(q)}
waiting={!matrixReady}
waitingText="Укажите заявителя и проект, чтобы построить маршрут согласования."
/>
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
@@ -20,12 +20,16 @@ import {
ApprovalMatrix,
ApprovalHistory,
useMatrixEditor,
useViewExecutors,
ExecutorsMatrix,
approvalPhaseRows,
notifyActiveStep,
useRequestTypeId,
isEditableStatus
} from '../../shared/approval';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import { useStableCallback } from '../../shared/useStableCallback';
import {
approvalListTitle,
approvalListTitleForListId,
@@ -54,6 +58,9 @@ interface IServiceRequestViewProps {
// Заявка «Заявка на услуги» только для просмотра (режим согласующего).
const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя, его ссылка меняется на каждом рендере; в зависимостях эффекта
// загрузки идёт стабильная обёртка, иначе заявка перезапрашивалась бы при каждом рендере родителя.
const notifyCode = useStableCallback(onCodeLoaded);
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>('');
@@ -77,10 +84,11 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
const [decisionSeq, setDecisionSeq] = useState<number>(0);
const loadApprovers = React.useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]);
const loadApprovers = React.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;
@@ -91,7 +99,7 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
setData(loaded.data);
setStatus(loaded.status);
setCode(loaded.code);
onCodeLoaded?.(loaded.code);
notifyCode(loaded.code);
setAttachments(files);
setError(null);
setLoading(false);
@@ -106,7 +114,7 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
return () => {
alive = false;
};
}, [itemId, listId]);
}, [itemId, listId, notifyCode]);
// Ссылки на скачивание для файлов строк «Приложения» (см. AnnexesSection).
// Считаем ДО ранних return'ов ниже: хук, вызванный только на части рендеров,
@@ -180,6 +188,7 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
</FormCard>
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
</div>
{/* История согласования: маршрут пересохраняется патчем (см.
@@ -43,7 +43,7 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
export type ISaveResult = ISaveItemResult;
/**
* Сохранить «Заявку на услуги»: контент + маршрут согласования.
* Сохранить «Заявку на услуги»: контент + маршрут согласования и исполнения.
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём
* новую. Возвращает Id заявки (+ уникальный код при создании).
*
@@ -56,14 +56,13 @@ export async function saveServiceRequest(
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(serviceRequestApprovalService(listId), result.id, approvers, [], status);
await writeRoute(serviceRequestApprovalService(listId), result.id, approvers, executors, status);
return result;
}
@@ -13,7 +13,9 @@ import {
useRequestSubmit
} from '../../shared/ui';
import {
composeRemoveRowRule,
ApprovalMatrix,
canEditExecutors,
ApprovalHistory,
StatusBadge,
notifyActiveStep,
@@ -26,6 +28,7 @@ import {
resolveWriteListId,
usePhasedRoute
} from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
@@ -132,7 +135,8 @@ const SkudExplanationForm: React.FC<IRequestFormProps> = ({
requestId: editId,
employeeEmail: data.Employee,
requestTypeId,
editable: !readOnly
editable: !readOnly,
executorsEditable: canEditExecutors(context, readOnly)
});
const validate = (): string | undefined => {
@@ -266,14 +270,20 @@ const SkudExplanationForm: React.FC<IRequestFormProps> = ({
<ExplanationSection {...sectionProps} />
</FormModeProvider>
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
<ApprovalMatrix
editor={matrix}
search={(q) => approval.searchUsers(q)}
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
/>
{!executors.loading && executors.rows.length > 0 && (
{!executors.loading && (executors.editable || executors.rows.length > 0) && (
<ApprovalMatrix
editor={executors}
search={(q) => approval.searchUsers(q)}
title="Исполнение (после согласования)"
roleLabel="Исполнитель"
canRemoveRow={executors.editable ? () => true : undefined}
pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…"
emptyText="Исполнители не заданы по маршруту."
/>
)}
@@ -87,8 +87,8 @@ describe('cellKind / toMissedKind', () => {
});
it('месяц начинается с понедельника: 1 июля 2026 — среда, два пустых места', () => {
expect(cells[0]).toBeNull();
expect(cells[1]).toBeNull();
expect(cells[0]).toBeUndefined();
expect(cells[1]).toBeUndefined();
expect(cells[2]?.day).toBe(1);
});
});
@@ -130,12 +130,12 @@ export function monthWorkedHours(byDay: Map<string, IDayInfo>): number {
return total;
}
/** Ячейки месяца: ведущие null — пустые места до первого числа (неделя с понедельника). */
export function buildMonthCells(year: number, month: number, byDay: Map<string, IDayInfo>): (IDayInfo | null)[] {
/** Ячейки месяца: ведущие undefined — пустые места до первого числа (неделя с понедельника). */
export function buildMonthCells(year: number, month: number, byDay: Map<string, IDayInfo>): (IDayInfo | undefined)[] {
const daysInMonth = new Date(year, month + 1, 0).getDate();
const firstColumn = (new Date(year, month, 1).getDay() + 6) % DAYS_IN_WEEK;
const cells: (IDayInfo | null)[] = [];
for (let i = 0; i < firstColumn; i += 1) cells.push(null);
const cells: (IDayInfo | undefined)[] = [];
for (let i = 0; i < firstColumn; i += 1) cells.push(undefined);
for (let d = 1; d <= daysInMonth; d += 1) {
const key = toDateKey(year, month, d);
cells.push(byDay.get(key) ?? { day: d, dateKey: key, events: [], first: '', last: '' });
@@ -29,7 +29,8 @@ import {
isEditableStatus,
IApproverToWrite,
unassignedStepMessage,
APPROVAL_PHASE
APPROVAL_PHASE,
executorsToWrite
} from '../../shared/approval';
import {
uploadAttachments,
@@ -306,7 +307,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
requestId,
data,
toApprovers(matrixRows),
toApprovers(executors.rows),
executorsToWrite(executors.rows),
finalStatus,
writeListId
);
@@ -17,10 +17,12 @@ import {
useRequestSubmit
} from '../../shared/ui';
import {
composeRemoveRowRule,
ApprovalMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
canEditExecutors,
getPlannedMatrix,
notifyActiveStep,
useRequestTypeId,
@@ -329,7 +331,7 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
const executors = useMatrixEditor(
loadExecutors,
`${userEmail}-${editId ?? 'new'}-${projectId}-${targetProjectId}`,
false,
canEditExecutors(context, readOnly),
executorsReady,
(login) => approval.getJobTitle(login)
);
@@ -511,6 +513,7 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
<ApprovalMatrix
editor={matrix}
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
search={(q) => approval.searchUsers(q)}
waiting={!matrixReady}
waitingText="Укажите заявителя и проект-источник («Откуда»), чтобы построить маршрут согласования."
@@ -518,12 +521,14 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
{/* Блок исполнителя показываем, только когда по маршруту реально есть
исполнители (грузится по проектам, см. executorsReady). */}
{executorsReady && !executors.loading && executors.rows.length > 0 && (
{executorsReady && !executors.loading && (executors.editable || executors.rows.length > 0) && (
<ApprovalMatrix
editor={executors}
search={(q) => approval.searchUsers(q)}
title="Исполнение (после согласования)"
roleLabel="Исполнитель"
canRemoveRow={executors.editable ? () => true : undefined}
pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…"
emptyText="Исполнители не заданы по маршруту."
/>
)}
@@ -25,11 +25,11 @@
- **Заголовок элемента** (`Title`): `Отпуск без содержания — {ФИО} — дд.мм.гггг`.
## Отличия от «Заявка на отпуск» (hr-vacation)
- Даты с/по вместо «дата начала + количество дней».
- Те же «дата начала + количество дней»: даты окончания нет, праздники и выходные не считаются.
- Добавлены поля «В связи с» (`Reason`) и «Лицо замещающее» (`Substitute`).
## Секции формы
RequestSection (сотрудник · должность · дата начала · дата окончания · основание · замещающий)
RequestSection (сотрудник · должность · дата начала · количество дней · основание · замещающий)
## Точки входа
- rptype: `hr-unpaid-leave`
@@ -13,10 +13,16 @@ import {
useRequestSubmit
} from '../../shared/ui';
import {
composeRemoveRowRule,
ApprovalMatrix,
ExecutorsMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
useFormExecutors,
approvalPhaseRows,
executorsToWrite,
initialSubmitStatus,
getPlannedMatrix,
notifyActiveStep,
useRequestTypeId,
@@ -26,6 +32,7 @@ import {
unassignedStepMessage,
resolveWriteListId
} from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
@@ -154,7 +161,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
const loadMatrix = useCallback(
() =>
editId
? approval.getMatrixForRequest(editId)
? approval.getMatrixForRequest(editId).then(approvalPhaseRows)
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval'),
[context, approval, editId, data.Employee, requestTypeId]
);
@@ -166,8 +173,19 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
(login) => approval.getJobTitle(login)
);
// Даты корректны, если конец не раньше начала (обе обязательны).
const datesValid = !!(data.StartDate && data.EndDate && data.EndDate >= data.StartDate);
// Исполнители фазы «Исполнение» — по маршруту из админки, только чтение.
const executors = useFormExecutors({
readOnly,
context,
approval,
requestTypeId,
userEmail: data.Employee,
editId
});
// Продолжительность: целое положительное число календарных дней.
const daysNum = Number(data.DaysCount);
const daysValid = Number.isFinite(daysNum) && daysNum > 0;
// Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала
// отпуска — календарь (min у input type=date, см. RequestSection) не даёт
// выбрать более раннюю дату, но проверяем и здесь на случай обхода UI.
@@ -176,7 +194,8 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
const isValid = !!(
data.Employee &&
data.Position.trim() &&
datesValid &&
data.StartDate &&
daysValid &&
isStartDateValid &&
data.Reason.trim()
);
@@ -184,10 +203,10 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
if (data.StartDate && !isStartDateValid) {
return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`;
}
if (!data.Employee || !data.Position.trim() || !data.StartDate || !data.EndDate || !data.Reason.trim()) {
return 'Заполните обязательные поля: сотрудник, должность, дата начала, дата окончания, основание.';
if (!data.Employee || !data.Position.trim() || !data.StartDate || !data.Reason.trim()) {
return 'Заполните обязательные поля: сотрудник, должность, дата начала, основание.';
}
if (!datesValid) return 'Дата окончания не может быть раньше даты начала.';
if (!daysValid) return 'Количество дней должно быть больше нуля.';
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined;
@@ -223,6 +242,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
rtId,
data,
toApprovers(matrix.rows),
executorsToWrite(executors.rows),
status,
writeListId
);
@@ -237,7 +257,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
// Имена списков — той же партиции, куда фактически записана заявка (writeListId),
// не текущей: поток ищет строку маршрута именно в НЕЙ.
if (status === REQUEST_STATUS.onApproval) {
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),
@@ -259,11 +279,22 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
);
if (unassignedError) return unassignedError;
const unassignedExecutorError = unassignedStepMessage(
executors.rows,
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
);
if (unassignedExecutorError) return unassignedExecutorError;
return undefined;
};
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
// Есть исполнители — initialSubmitStatus учитывает маршрут без согласующих.
const handleSubmit = (): Promise<void> =>
submit.run(
'submit',
save(executors.rows.length > 0 ? initialSubmitStatus(matrix.rows.length, executors.rows.length) : REQUEST_STATUS.onApproval),
validateSubmit
);
const sectionProps = { data, set, context, onEmployeeChange: handleEmployeeChange };
@@ -314,7 +345,13 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
<RequestSection {...sectionProps} />
</FormModeProvider>
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
<ApprovalMatrix
editor={matrix}
search={(q) => approval.searchUsers(q)}
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
/>
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
@@ -8,8 +8,11 @@ import {
DecisionPanel,
StatusBadge,
ApprovalMatrix,
ExecutorsMatrix,
ApprovalHistory,
useMatrixEditor,
useViewExecutors,
approvalPhaseRows,
notifyActiveStep,
useRequestTypeId
} from '../../shared/approval';
@@ -70,7 +73,8 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
const [decisionSeq, setDecisionSeq] = useState<number>(0);
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]);
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
const executors = useViewExecutors(context, approval, itemId, decisionSeq);
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
approval.getJobTitle(l)
);
@@ -150,6 +154,8 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
</FormCard>
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
</div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
@@ -28,7 +28,7 @@ export async function loadUnpaidLeaveRequest(itemId: number, listId?: string): P
'Id',
'Position',
'StartDate',
'EndDate',
'DaysCount',
'Reason',
'Organization',
'Department',
@@ -46,14 +46,14 @@ export async function loadUnpaidLeaveRequest(itemId: number, listId?: string): P
const employee = item.Employee as { Title?: string; EMail?: string } | undefined;
const substitute = item.Substitute as { EMail?: string } | undefined;
const start = (item.StartDate as string) || '';
const end = (item.EndDate as string) || '';
const days = item.DaysCount;
const data: IFormData = {
...EMPTY_DATA,
Employee: employee?.EMail || '',
Position: (item.Position as string) || '',
StartDate: spDateOnlyToIso(start),
EndDate: spDateOnlyToIso(end),
DaysCount: days === null || days === undefined ? '' : String(days),
Reason: (item.Reason as string) || '',
Substitute: substitute?.EMail || '',
Organization: (item.Organization as string) || '',
@@ -12,8 +12,8 @@ export const FORM_ID: RequestTypeId = 'hr-unpaid-leave';
* по возможности = internalName колонок списка данных (см. shared/listTitles.ts).
* Employee/Substitute хранят e-mail выбранного сотрудника (то, что отдаёт
* PeopleField); по Employee резолвится маршрут согласования, поэтому заявку можно
* подать и за другого сотрудника. В отличие от «Заявки на отпуск» здесь обе даты
* (с/по) вводятся явно, а DaysCount считается при сохранении.
* подать и за другого сотрудника. Как и в «Заявке на отпуск», даты окончания нет:
* отпуск задаётся началом и количеством дней, чтобы не считать праздники и выходные.
*/
export interface IFormData {
/** E-mail сотрудника, за которого заявка (People). Пусто = не выбран. */
@@ -21,8 +21,8 @@ export interface IFormData {
Position: string;
/** ISO YYYY-MM-DD (input type=date). */
StartDate: string;
/** ISO YYYY-MM-DD (input type=date). */
EndDate: string;
/** Количество календарных дней (строка из input type=number). */
DaysCount: string;
/** Основание «В связи с …». */
Reason: string;
/** E-mail замещающего на время отсутствия (People). Пусто = не выбран. */
@@ -68,7 +68,7 @@ export const EMPTY_DATA: IFormData = {
Employee: '',
Position: '',
StartDate: '',
EndDate: '',
DaysCount: '',
Reason: '',
Substitute: '',
Organization: '',
@@ -7,7 +7,6 @@
{ "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
{ "internalName": "Position", "title": "Должность", "type": "Text" },
{ "internalName": "StartDate", "title": "Дата начала", "type": "DateTime", "dateOnly": true },
{ "internalName": "EndDate", "title": "Дата окончания", "type": "DateTime", "dateOnly": true },
{ "internalName": "DaysCount", "title": "Количество дней", "type": "Number" },
{ "internalName": "Reason", "title": "Основание (в связи с)", "type": "Note" },
{ "internalName": "Substitute", "title": "Лицо, замещающее на время отсутствия", "type": "User", "userSelectionMode": "PeopleOnly" },
@@ -51,8 +51,8 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
min={isoDatePlusDays(MIN_NOTICE_DAYS)}
/>
</FormField>
<FormField label="Дата окончания" htmlFor="EndDate" required>
<TextInput id="EndDate" type="date" value={data.EndDate} onChange={(v) => set('EndDate', v)} />
<FormField label="Количество дней" htmlFor="DaysCount" required>
<TextInput id="DaysCount" type="number" value={data.DaysCount} onChange={(v) => set('DaysCount', v)} />
</FormField>
<FormField label="В связи с" htmlFor="Reason" required full>
<TextArea id="Reason" value={data.Reason} onChange={(v) => set('Reason', v)} />
@@ -16,21 +16,13 @@ function pad2(n: number): string {
return n < 10 ? `0${n}` : `${n}`;
}
/** Число дней отпуска включительно по датам с/по. Пусто/некорректно → пусто (null). */
function computeDays(startIso: string, endIso: string): number | undefined {
if (!startIso || !endIso) return undefined;
const diff = new Date(endIso).getTime() - new Date(startIso).getTime();
if (!Number.isFinite(diff) || diff < 0) return undefined;
return Math.round(diff / 86400000) + 1;
}
/** Осмысленный заголовок элемента (системная колонка Title). */
function buildTitle(employeeName: string): string {
const now = new Date();
return `Отпуск без содержания — ${employeeName} — ${pad2(now.getDate())}.${pad2(now.getMonth() + 1)}.${now.getFullYear()}`;
}
/** Поля элемента списка данных из модели формы (+ Employee/Substitute по Id, + вычисленный DaysCount). */
/** Поля элемента списка данных из модели формы (+ Employee/Substitute по Id). */
function buildItem(
data: IFormData,
status: string,
@@ -38,15 +30,14 @@ function buildItem(
employeeName: string,
substituteId: number | undefined
): Record<string, unknown> {
const days = computeDays(data.StartDate, data.EndDate);
const days = Number(data.DaysCount);
return {
Title: buildTitle(employeeName),
RequestStatus: status,
EmployeeId: employeeId,
Position: data.Position || null,
StartDate: data.StartDate || null,
EndDate: data.EndDate || null,
DaysCount: days ?? null,
DaysCount: Number.isFinite(days) && days > 0 ? days : null,
Reason: data.Reason || null,
SubstituteId: substituteId ?? null,
Organization: data.Organization || null,
@@ -62,7 +53,7 @@ export type ISaveResult = ISaveItemResult;
/**
* Сохранить заявку без содержания: содержимое + маршрут согласования.
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей.
* executors — исполнители фазы «Исполнение» из маршрута (пусто, если в маршруте их нет).
* Возвращает Id заявки (+ код при создании).
*
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadUnpaidLeaveRequest). Имеет смысл
@@ -75,6 +66,7 @@ export async function saveUnpaidLeaveRequest(
requestTypeId: number,
data: IFormData,
approvers: IApproverToWrite[],
executors: IApproverToWrite[],
status: string,
listId?: string
): Promise<ISaveResult> {
@@ -84,6 +76,6 @@ export async function saveUnpaidLeaveRequest(
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee, substituteId);
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
await writeRoute(unpaidLeaveApprovalService(listId), result.id, approvers, [], status);
await writeRoute(unpaidLeaveApprovalService(listId), result.id, approvers, executors, status);
return result;
}
@@ -8,8 +8,9 @@
## Маршрут
- Тип заявки в approval-route API (`/api/request-types`): id **11**, `pageUrl` «hr-vacation»,
`listId` «/sites/requests/Lists/List5», section «Кадры».
- Фазы: только согласование. Фазы исполнения нет — после согласования заявка
просто получает статус «Согласовано» (никакого Planner, в отличие от Ticket1C).
- Фазы: согласование, а при наличии исполнителей в маршруте (бывает: тип заявки может их
содержать) — ещё и исполнение. Без исполнителей после согласования заявка получает статус
«Согласовано», с исполнителем — сразу «Выполнение». Planner не используется (в отличие от Ticket1C).
- **Матрица редактируема заявителем** при новой заявке / черновике / возврате
(как RecruitingHire / WorkplaceSetup): `useMatrixEditor(editable = !readOnly)`.
Заявитель может добавлять/удалять/переставлять согласующих поверх планируемого
@@ -24,10 +25,48 @@
пользователем (email + должность из профиля `SPS-JobTitle` через
`approval.getJobTitle`). При выборе другого сотрудника его должность
подтягивается так же. Ошибка профиля не мешает форме — поле просто остаётся как есть.
- **Дата окончания не вводится** — считается при сохранении: `начало + (дни − 1)`
и пишется в колонку `EndDate` (обязательна в списке, но в UI её нет).
- **Даты окончания нет**: отпуск задаётся началом и количеством дней, чтобы не считать
праздники и выходные. Колонка `EndDate` не используется: из провижининга, типа контента и представления она убрана (само системное поле SharePoint `FromBaseType` удалить нельзя).
- **Заголовок элемента** (`Title`): `Отпуск — {ФИО} — дд.мм.гггг`.
## Подписание в ЭДО
Когда **последний согласующий** нажимает «Согласовать», заявление сначала уходит на подпись в TANSU ЭДО
(`VacationView` → `onBeforeDecide`), и только потом применяется решение. Последний — значит заявка
«На согласовании» и несогласованной осталась одна строка фазы согласования (`completesApprovalPhase`;
исполнители не считаются, параллельные согласующие учтены). **Не ушло в ЭДО — решение не сохраняется**:
заявка остаётся на согласовании, ошибка видна в панели решения, согласовать можно ещё раз. Иначе заявка
ушла бы дальше по маршруту, а подписывать было бы нечего. Повтор безопасен: DOCX собирается одинаково
(фиксированная дата записей архива), и уже принятые файлы ЭДО узнаёт по хэшу — ответ `duplicate_document`
считается успехом. В зелёном сообщении «Решение сохранено» появляется «Документы отправлены на подписание
в ЭДО». Кнопка «Повторить отправку на подпись» осталась для заявок, согласованных до этой схемы:
- **Кто подписывает** — сотрудник, указанный в заявке (поле «Сотрудник»); ему уходит приглашение.
**Инициатор** (`initiator_email`, письма о подписях) — исполнитель заявки, первый в фазе исполнения; нет
исполнителя — уведомлений о подписях нет.
- **Брендированная шапка** — логотип компании первой строкой документа (тот же, что при печати,
`shared/letterheadLogo.ts` → `shared/orgLogos.ts`). Логотипы вшиты в бандл, поэтому у компании, для
которой логотип есть, документ без шапки уйти не может. У компании без логотипа шапки нет — как и в печати.
- **Документ** — заявление, собранное в браузере как DOCX (`vacationDocument.ts`, JSZip); ЭДО сам
переводит его в PDF. Текст — рабочий вариант, финальную формулировку утверждает кадровая служба.
- **Совместители.** Заявку сотрудник заполняет одну, а подписывает заявление по каждой компании, где
работает: в ЭДО уходит по файлу на компанию (своя шапка — компания и должность в ней), БИН файлов — в
массиве `file_company_bins`, общий `company_bin` не передаётся. Подписант один — сам сотрудник, от
имени физического лица; БИН нужны ЭДО только чтобы выбрать сертификат Sigex (mTLS) нужной компании.
Запрос один на все файлы: ЭДО принимает его целиком или отклоняет целиком (например, если у какой-то
компании не распознан), поэтому повтор безопасен. Сертификат Sigex компании нужен ЭДО только для mTLS
(карточка документа): без него документ принимается и подписывается как обычно. Ответ 200 значит
«принято»: в зелёном сообщении просто «Документы отправлены на подписание в ЭДО».
- **ИИН** берётся из `GET /Employee/ByEmail` (поле `Iin`), **БИН компаний** — из `GET /Dictionary/Companies`
по `CompanyId` каждой записи сотрудника (`shared/edo/signingTarget.ts`). Компания без БИН в справочнике
пропускается и показывается в сообщении; остальные уходят.
- **С каким ЭДО работать** — свойство веб-части «С каким ЭДО работать» (`edoApiBaseUrl`): стейдж или
локальный бэкенд. По умолчанию стейдж. Токен берётся для того же AAD-приложения, что у approval-route.
- **Отпуск без содержания в ЭДО не подписывается** (`UnpaidLeave`): подписание подключено только к этой форме.
- **Неудача отправки** показывается сообщением над панелью решения и согласование не отменяет; рядом
кнопка «Повторить отправку на подпись». Признака «уже отправлено» в заявке нет, поэтому после
перезагрузки страницы повтор недоступен.
- **Ограничение:** срабатывает только если финальное решение принято в UI портала. Автосогласование по
SLA на бэкенде approval-route этот код не вызывает — такое заявление на подпись не уйдёт.
## Секции формы
RequestSection (сотрудник · должность · дата начала · количество дней)
@@ -13,10 +13,16 @@ import {
useRequestSubmit
} from '../../shared/ui';
import {
composeRemoveRowRule,
ApprovalMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
useFormExecutors,
ExecutorsMatrix,
approvalPhaseRows,
executorsToWrite,
initialSubmitStatus,
getPlannedMatrix,
resolveWriteListId,
notifyActiveStep,
@@ -26,8 +32,9 @@ import {
IApproverToWrite,
unassignedStepMessage
} from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import { getEmployeeOrganizations, formatEmployeeOrganizationsWithDepartment, IEmployeeOrganization } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
import {
@@ -102,6 +109,15 @@ const VacationForm: React.FC<IRequestFormProps> = ({
};
}, [editId, editListId, notifyCode, setSubmitError]);
// Должность и подразделение больше не вводятся руками: берутся из ПЕРВОЙ (основной)
// записи Employee/ByEmail и пишутся в скалярные колонки Position/Department (их
// читают документ и ЭДО); по каждой организации то же видно в таблице (Organization).
const primaryOrgFields = (records: IEmployeeOrganization[]): Pick<IFormData, 'Position' | 'Department' | 'DepartmentId'> => ({
Position: records[0]?.position || '',
Department: records[0]?.section || '',
DepartmentId: records[0]?.departmentId || ''
});
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя,
// его должность и организации (Employee/ByEmail — см. shared/dictionary/
// employeeOrganizations.service.ts; сотрудник может состоять сразу в
@@ -118,8 +134,8 @@ const VacationForm: React.FC<IRequestFormProps> = ({
if (!alive) return;
setData((prev) => ({
...prev,
Position: prev.Position || records[0]?.position || prev.Position,
Organization: formatEmployeeOrganizations(records)
...primaryOrgFields(records),
Organization: formatEmployeeOrganizationsWithDepartment(records)
}));
})
.catch(() => undefined);
@@ -135,15 +151,15 @@ const VacationForm: React.FC<IRequestFormProps> = ({
const handleEmployeeChange = (login: string): void => {
setData((prev) => ({ ...prev, Employee: login }));
if (!login) {
setData((prev) => ({ ...prev, Organization: '' }));
setData((prev) => ({ ...prev, ...primaryOrgFields([]), Organization: '' }));
return;
}
getEmployeeOrganizations(context, login)
.then((records) =>
setData((prev) => ({
...prev,
Position: records[0]?.position || prev.Position,
Organization: formatEmployeeOrganizations(records)
...primaryOrgFields(records),
Organization: formatEmployeeOrganizationsWithDepartment(records)
}))
)
.catch(() => undefined);
@@ -155,7 +171,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
const loadMatrix = useCallback(
() =>
editId
? approval.getMatrixForRequest(editId)
? approval.getMatrixForRequest(editId).then(approvalPhaseRows)
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval'),
[context, approval, editId, data.Employee, requestTypeId]
);
@@ -167,6 +183,17 @@ const VacationForm: React.FC<IRequestFormProps> = ({
(login) => approval.getJobTitle(login)
);
// Исполнители из маршрута админки (фаза «Исполнение»): только чтение, таблица
// и запись в заявку — когда они в маршруте есть.
const executors = useFormExecutors({
readOnly,
context,
approval,
requestTypeId,
userEmail: data.Employee,
editId
});
const daysNum = Number(data.DaysCount);
// Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала
// отпуска — календарь (min у input type=date, см. RequestSection) не даёт
@@ -175,7 +202,6 @@ const VacationForm: React.FC<IRequestFormProps> = ({
const isStartDateValid = !data.StartDate || data.StartDate >= minStartDate;
const isValid = !!(
data.Employee &&
data.Position.trim() &&
data.StartDate &&
isStartDateValid &&
Number.isFinite(daysNum) &&
@@ -185,7 +211,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
if (data.StartDate && !isStartDateValid) {
return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`;
}
if (!isValid) return 'Заполните обязательные поля: сотрудник, должность, дата начала, количество дней (> 0).';
if (!isValid) return 'Заполните обязательные поля: сотрудник, дата начала, количество дней (> 0).';
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined;
@@ -221,6 +247,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
rtId,
data,
toApprovers(matrix.rows),
executorsToWrite(executors.rows),
status,
writeListId
);
@@ -236,7 +263,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
// Имена списков — той же партиции, куда фактически записали (writeListId),
// не обязательно текущей на момент рендера формы: поток ищет строку
// маршрута именно в НЕЙ.
if (status === REQUEST_STATUS.onApproval) {
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),
@@ -258,11 +285,27 @@ const VacationForm: React.FC<IRequestFormProps> = ({
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
);
if (unassignedError) return unassignedError;
const unassignedExecutorError = unassignedStepMessage(
executors.rows,
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
);
if (unassignedExecutorError) return unassignedExecutorError;
return undefined;
};
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
// Есть исполнители — initialSubmitStatus учтёт и маршрут без согласующих (сразу «Выполнение»);
// иначе прежнее поведение: «На согласовании».
const handleSubmit = (): Promise<void> =>
submit.run(
'submit',
save(
executors.rows.length > 0
? initialSubmitStatus(matrix.rows.length, executors.rows.length)
: REQUEST_STATUS.onApproval
),
validateSubmit
);
const sectionProps = { data, set, context, onEmployeeChange: handleEmployeeChange };
@@ -313,7 +356,13 @@ const VacationForm: React.FC<IRequestFormProps> = ({
<RequestSection {...sectionProps} />
</FormModeProvider>
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
<ApprovalMatrix
editor={matrix}
search={(q) => approval.searchUsers(q)}
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
/>
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
@@ -1,8 +1,8 @@
import * as React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './VacationView.module.scss';
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
import {
ApproverService,
DecisionPanel,
@@ -10,9 +10,16 @@ import {
ApprovalMatrix,
ApprovalHistory,
useMatrixEditor,
useViewExecutors,
ExecutorsMatrix,
approvalPhaseRows,
notifyActiveStep,
useRequestTypeId
} from '../../shared/approval';
import { completesApprovalPhase } from '../../shared/approval/models/statuses';
import type { ApproverDecision } from '../../shared/approval';
import { isAlreadyAccepted } from '../../shared/edo/edoErrors';
import { executionPhaseRows } from '../../shared/approval/useRouteExecutors';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
approvalListTitle,
@@ -23,6 +30,7 @@ import {
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadVacationRequest } from './load';
import { vacationApprovalService } from './submit';
import { IVacationSigningResult, sendVacationForSigning } from './sendToSign';
import { RequestSection } from './sections/RequestSection';
import { useStableCallback } from '../../shared/useStableCallback';
@@ -52,11 +60,18 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>('');
// Ф.И.О. из заявки (Employee/Title) — запасной вариант для документа на подпись.
const [employeeName, setEmployeeName] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
// выбор в тулбаре через PrintSplitButton, по умолчанию первая.
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
// Итог отправки согласованного заявления на подпись в ЭДО (см. sendToSigning): по каким
// компаниям ушло, по каким нельзя, либо ошибка всего запроса.
const [signingResult, setSigningResult] = useState<IVacationSigningResult | undefined>(undefined);
const [signingError, setSigningError] = useState<string>('');
const [signingBusy, setSigningBusy] = useState<boolean>(false);
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
// vacationApprovalService можно звать не опасаясь исключения.
@@ -70,10 +85,14 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
const [decisionSeq, setDecisionSeq] = useState<number>(0);
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]);
// Заявление ушло в ЭДО перед этим решением (см. sendBeforeFinalApproval) — сказать об этом в «Решение сохранено».
const sentBeforeDecision = useRef<boolean>(false);
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;
@@ -84,6 +103,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
setData(loaded.data);
setStatus(loaded.status);
setCode(loaded.code);
setEmployeeName(loaded.employeeName);
notifyCode(loaded.code);
setError(null);
setLoading(false);
@@ -125,6 +145,50 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
}).catch(() => undefined);
};
// Заявление уходит на подпись в ЭДО (по документу на каждую компанию сотрудника, одним запросом).
// Исполнитель — первый в фазе исполнения маршрута: он инициатор в ЭДО. Повтор безопасен: файл
// собирается одинаково, и уже принятое ЭДО узнаёт по хэшу (duplicate_document) — это тоже успех.
const submitToEdo = async (): Promise<void> => {
setSigningError('');
setSigningResult(undefined);
setSigningBusy(true);
try {
const rows = await approval.getMatrixForRequest(itemId);
const initiatorEmail = executionPhaseRows(rows).filter((r) => r.email)[0]?.email;
try {
setSigningResult(await sendVacationForSigning(context, { data, employeeName, itemId, code, initiatorEmail }));
} catch (e) {
if (!isAlreadyAccepted(e)) throw e;
}
} finally {
setSigningBusy(false);
}
};
// Решение последнего согласующего применяется, только когда заявление уже в ЭДО: иначе заявка
// ушла бы дальше по маршруту, а подписывать было бы нечего. Не ушло — решение не сохраняется,
// заявка остаётся на согласовании, и ошибку видно в панели решения; согласовать можно ещё раз.
const sendBeforeFinalApproval = async (decision: ApproverDecision): Promise<void> => {
sentBeforeDecision.current = false;
if (decision !== 'approve') return;
if (!completesApprovalPhase(status, await approval.getMatrixForRequest(itemId))) return;
try {
await submitToEdo();
} catch (e) {
throw new Error(`заявление не ушло на подпись в ЭДО, заявка осталась на согласовании — ${friendlyErrorMessage(e)}`);
}
sentBeforeDecision.current = true;
};
// Для заявок, согласованных раньше, когда отправка шла после решения и могла не удаться.
const retrySigning = (): void => {
submitToEdo().catch((e) =>
setSigningError(`Заявка согласована, но отправить заявление на подпись не удалось: ${friendlyErrorMessage(e)}`)
);
};
return (
<div>
<div className={`${styles.toolbar} rp-no-print`}>
@@ -150,6 +214,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
</FormCard>
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
</div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
@@ -160,14 +225,30 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
</div>
<div className="rp-no-print">
{signingBusy && <Alert variant="ok">Отправляем заявление на подпись в ЭДО…</Alert>}
{!signingBusy && signingResult && signingResult.skipped.length > 0 && (
<Alert variant="err">
{`Не отправлено по компаниям: ${signingResult.skipped.map((c) => `${c.name} — ${c.reason}`).join('; ')}.`}
</Alert>
)}
{!signingBusy && signingError && (
<>
<Alert variant="err">{signingError}</Alert>
<Button variant="secondary" onClick={retrySigning}>
Повторить отправку на подпись
</Button>
</>
)}
<DecisionPanel
service={approverService}
requestId={itemId}
search={(q) => approval.searchUsers(q)}
onBeforeDecide={sendBeforeFinalApproval}
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
return sentBeforeDecision.current ? 'Документы отправлены на подписание в ЭДО.' : undefined;
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);
@@ -0,0 +1,5 @@
/** ISO `YYYY-MM-DD` → `дд.мм.гггг`. Не похоже на дату → строка как есть (чтобы документ не падал из-за мусора). */
export function formatRuDate(iso: string): string {
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
return match ? `${match[3]}.${match[2]}.${match[1]}` : iso;
}
@@ -12,7 +12,7 @@ export const FORM_ID: RequestTypeId = 'hr-vacation';
* списка данных этой формы (см. shared/listTitles.ts). Employee хранит e-mail выбранного
* сотрудника (то, что отдаёт PeopleField) — по нему же резолвится маршрут
* согласования, поэтому заявку можно подать и за другого сотрудника.
* EndDate в форме не вводится: считается при сохранении как начало + (дни − 1).
* Даты окончания нет: отпуск задаётся началом и количеством дней, праздники и выходные не считаются.
*/
export interface IFormData {
/** E-mail сотрудника, за которого заявка (People). Пусто = не выбран. */
@@ -7,7 +7,6 @@
{ "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
{ "internalName": "Position", "title": "Должность", "type": "Text" },
{ "internalName": "StartDate", "title": "Дата начала", "type": "DateTime", "dateOnly": true },
{ "internalName": "EndDate", "title": "Дата окончания", "type": "DateTime", "dateOnly": true },
{ "internalName": "DaysCount", "title": "Количество дней", "type": "Number" },
{ "internalName": "Substitute", "title": "Лицо, замещающее на время отсутствия", "type": "User", "userSelectionMode": "PeopleOnly" },
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
@@ -1,10 +1,10 @@
import * as React from 'react';
import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary';
import { FormGrid, FormField, TextInput, PeopleField, OrganizationTable } from '../../../shared/ui';
import { ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model';
import { isoDatePlusDays } from '../../../shared/dateUtils';
/** Единственная секция формы «Заявка на отпуск»: сотрудник, должность, даты. */
/** Единственная секция формы «Заявка на отпуск»: сотрудник (должность и подразделение подтягиваются из справочника), даты. */
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => (
<FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full>
@@ -15,21 +15,7 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
placeholder={EMPLOYEE_PLACEHOLDER}
/>
</FormField>
<FormField label="Должность" htmlFor="Position" required>
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
</FormField>
<OrganizationField value={data.Organization} printIndex={printOrgIndex} />
<FormField label="Подразделение" htmlFor="Department">
<DepartmentPicker
id="Department"
context={context}
value={data.Department}
onChange={(name, id) => {
set('Department', name);
set('DepartmentId', id);
}}
/>
</FormField>
<OrganizationTable value={data.Organization} printIndex={printOrgIndex} />
<FormField label="Проект" htmlFor="Project">
<ProjectPicker
id="Project"
@@ -0,0 +1,120 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { letterheadLogoFor } from '../../shared/letterheadLogo';
import { getCompanies, getEmployeeOrganizations } from '../../shared/dictionary';
import { submitSigningRequest } from '../../shared/edo/edo.service';
import { ISigningCompany, ISkippedCompany, resolveSigningTargets } from '../../shared/edo/signingTarget';
import { formatRuDate } from './dates';
import { IFormData } from './model';
import { createVacationDocx, vacationFileName } from './vacationDocument';
/** Что нужно знать о согласованной заявке, чтобы отправить её на подпись. */
export interface IVacationToSign {
/** Данные заявки (прочитаны из списка). */
data: IFormData;
/** Ф.И.О. сотрудника из заявки (`Employee/Title`) — запасной вариант, если справочник пуст. */
employeeName: string;
/** Уникальный код заявки (`RequestUniqueCode`); пусто — берётся id. */
code: string;
itemId: number;
/**
* Почта исполнителя заявки (тот, кто идёт в маршруте после согласующих): он «инициатор» в ЭДО, и
* на его почту придёт сообщение о подписи. Нет исполнителя в маршруте — инициатора нет, и никто не уведомляется о подписях.
*/
initiatorEmail?: string;
}
/** Итог отправки: по каким компаниям документ ушёл и по каким нельзя. */
export interface IVacationSigningResult {
/** Названия компаний, по которым заявление отправлено на подпись. */
sent: string[];
/** Компании сотрудника, по которым документ не собрать (нет БИН в справочнике), и почему. */
skipped: ISkippedCompany[];
}
/** Сегодняшняя дата `дд.мм.гггг` по местному времени — дата составления заявления. */
function todayRu(): string {
const now = new Date();
const pad = (n: number): string => (n < 10 ? `0${n}` : `${n}`);
return `${pad(now.getDate())}.${pad(now.getMonth() + 1)}.${now.getFullYear()}`;
}
/** Ф.И.О. замещающего по его e-mail; не нашли — сам адрес, чтобы заявление всё равно собралось. */
async function substituteName(context: WebPartContext, email: string): Promise<string> {
if (!email) return '';
try {
const records = await getEmployeeOrganizations(context, email);
return records[0]?.fio || email;
} catch {
// Имя замещающего — украшение документа: из-за него отправку не срываем.
return email;
}
}
/**
* Отправляет согласованное заявление на отпуск на подпись в ЭДО — отдельным файлом на каждую
* компанию, где работает сотрудник: заявку он заполняет одну, а подписывает заявление в каждой
* компании. У каждого файла своя шапка (компания, должность) и свой БИН в `file_company_bins`;
* подписант один — сам сотрудник, от имени физического лица. БИН нужны ЭДО только для выбора
* сертификата Sigex (mTLS) нужной компании.
*
* Запрос один на все файлы: ЭДО принимает его целиком или отклоняет целиком (например, если у
* одной из компаний нет сертификата), поэтому «часть ушла, часть нет» не бывает и повтор безопасен.
*
* Подписывает сотрудник, указанный в заявке: ему уходят приглашения. Инициатор — исполнитель заявки:
* на его почту ЭДО сообщит о подписи. ИИН и БИН берутся из справочников api.tnsu.kz.
*/
export async function sendVacationForSigning(
context: WebPartContext,
vacation: IVacationToSign
): Promise<IVacationSigningResult> {
const { data } = vacation;
if (!data.Employee) {
throw new Error('в заявке не указан сотрудник');
}
const [records, companies] = await Promise.all([getEmployeeOrganizations(context, data.Employee), getCompanies(context)]);
const targets = resolveSigningTargets(records, companies);
if (targets.companies.length === 0) {
const reasons = targets.skipped.map((c) => `${c.name} — ${c.reason}`).join('; ');
throw new Error(`ни по одной компании сотрудника заявление не отправить: ${reasons}`);
}
const days = Number(data.DaysCount);
const documentDate = todayRu();
const employeeName =
[targets.name.lastName, targets.name.firstName, targets.name.middleName].filter((p) => p).join(' ') ||
vacation.employeeName;
const substitute = await substituteName(context, data.Substitute);
const files = await Promise.all(
targets.companies.map(async (company: ISigningCompany) => ({
name: vacationFileName(employeeName, company.name, documentDate),
companyBin: company.bin,
content: await createVacationDocx({
companyName: company.name,
// В каждой компании своя должность; нет её в справочнике — та, что в заявке.
position: company.position || data.Position,
employeeName,
startDate: formatRuDate(data.StartDate),
daysCount: days,
substituteName: substitute,
requestCode: vacation.code || String(vacation.itemId),
documentDate,
// Брендированная шапка — тот же логотип, что при печати; у компаний без логотипа её нет.
logo: letterheadLogoFor(company.name)
})
}))
);
await submitSigningRequest(context, {
recipient: { iin: targets.iin, email: data.Employee, name: targets.name },
initiatorEmail: vacation.initiatorEmail,
files
});
return {
sent: targets.companies.map((c) => c.name),
skipped: targets.skipped
};
}
@@ -16,20 +16,13 @@ function pad2(n: number): string {
return n < 10 ? `0${n}` : `${n}`;
}
/** «Дата окончания» в форме не вводится: начало + (дни − 1). Пусто → ''. */
function computeEndDate(startIso: string, days: number): string {
if (!startIso || !Number.isFinite(days) || days <= 0) return '';
const endMs = new Date(startIso).getTime() + (days - 1) * 86400000;
return new Date(endMs).toISOString().slice(0, 10);
}
/** Осмысленный заголовок элемента (системная колонка Title). */
function buildTitle(employeeName: string): string {
const now = new Date();
return `Отпуск — ${employeeName} — ${pad2(now.getDate())}.${pad2(now.getMonth() + 1)}.${now.getFullYear()}`;
}
/** Поля элемента списка данных из модели формы (+ Employee/Substitute по Id, + вычисленная EndDate). */
/** Поля элемента списка данных из модели формы (+ Employee/Substitute по Id). */
function buildItem(
data: IFormData,
status: string,
@@ -45,7 +38,6 @@ function buildItem(
Position: data.Position || null,
StartDate: data.StartDate || null,
DaysCount: Number.isFinite(days) && days > 0 ? days : null,
EndDate: computeEndDate(data.StartDate, days) || null,
SubstituteId: substituteId ?? null,
Organization: data.Organization || null,
Department: data.Department || null,
@@ -60,7 +52,7 @@ export type ISaveResult = ISaveItemResult;
/**
* Сохранить заявку на отпуск: содержимое + маршрут согласования.
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Фазы исполнения у отпуска нет — writeRoute вызывается с пустым списком исполнителей.
* executors — исполнители фазы «Исполнение» из маршрута админки (пусто, если в маршруте их нет).
* Возвращает Id заявки (+ код при создании).
*
* listId — партиция, в которую фактически пишем: при редактировании — партиция
@@ -76,6 +68,7 @@ export async function saveVacationRequest(
requestTypeId: number,
data: IFormData,
approvers: IApproverToWrite[],
executors: IApproverToWrite[],
status: string,
listId?: string
): Promise<ISaveResult> {
@@ -85,6 +78,6 @@ export async function saveVacationRequest(
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee, substituteId);
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
await writeRoute(vacationApprovalService(listId), result.id, approvers, [], status);
await writeRoute(vacationApprovalService(listId), result.id, approvers, executors, status);
return result;
}
@@ -0,0 +1,122 @@
import JSZip from 'jszip';
import { formatRuDate } from './dates';
import {
IVacationDocumentInput,
buildDocumentXml,
createVacationDocx,
escapeXml,
vacationFileName
} from './vacationDocument';
import { letterheadLogoFor, pngSize } from '../../shared/letterheadLogo';
const INPUT: IVacationDocumentInput = {
companyName: 'ТОО TANSU Construction',
position: 'Инженер',
employeeName: 'Иванов Иван Иванович',
startDate: '01.12.2026',
daysCount: 14,
substituteName: 'Петров Пётр Петрович',
requestCode: 'HR-0042',
documentDate: '30.09.2026',
logo: letterheadLogoFor('ТОО TANSU Construction')
};
/** Blob → байты через FileReader: в окружении Jest (jsdom) нет ни Response, ни blob.arrayBuffer(). */
function readBlob(blob: Blob): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as ArrayBuffer);
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(blob);
});
}
describe('даты отпуска', () => {
it('формат дд.мм.гггг, мусор остаётся как есть', () => {
expect(formatRuDate('2026-12-01')).toBe('01.12.2026');
expect(formatRuDate('завтра')).toBe('завтра');
});
});
describe('заявление на отпуск', () => {
it('в тексте есть все данные заявки', () => {
const xml = buildDocumentXml(INPUT);
for (const part of [
'ТОО TANSU Construction',
'Инженер',
'Иванов Иван Иванович',
'ЗАЯВЛЕНИЕ',
'14 календарных дней, начиная с 01.12.2026',
'Петров Пётр Петрович',
'HR-0042',
'30.09.2026'
]) {
expect(xml).toContain(part);
}
});
it('без замещающего абзаца о замещении нет', () => {
expect(buildDocumentXml({ ...INPUT, substituteName: '' })).not.toContain('исполнение обязанностей');
});
it('специальные символы в данных экранируются, разметка не ломается', () => {
const xml = buildDocumentXml({ ...INPUT, companyName: 'ТОО "Рога & Копыта" <филиал>' });
expect(xml).toContain('ТОО &quot;Рога &amp; Копыта&quot; &lt;филиал&gt;');
expect(xml).not.toContain('Рога & Копыта');
});
it('экранирование', () => {
expect(escapeXml(`<a href="x">&'</a>`)).toBe('&lt;a href=&quot;x&quot;&gt;&amp;&apos;&lt;/a&gt;');
});
it('имя файла без недопустимых символов', () => {
expect(vacationFileName('Иванов И.И.', 'ТОО TANSU Construction', '30.09.2026')).toBe(
'Заявление на отпуск — Иванов И.И. — ТОО TANSU Construction — 30.09.2026.docx'
);
expect(vacationFileName('A/B:C', 'ТОО "X"', '01.01.2026')).not.toMatch(/[\\/:*?"<>|]/);
});
it('DOCX — это zip с обязательными частями пакета', async () => {
const blob = await createVacationDocx(INPUT);
const zip = await JSZip.loadAsync(await readBlob(blob));
expect(Object.keys(zip.files).sort()).toEqual([
'[Content_Types].xml',
'_rels/.rels',
'word/_rels/document.xml.rels',
'word/document.xml',
'word/media/logo.png'
]);
const document = await zip.file('word/document.xml')!.async('string');
expect(document).toContain('ЗАЯВЛЕНИЕ');
// Шапка — первая в документе, раньше текста.
expect(document.indexOf('r:embed="rIdLogo"')).toBeLessThan(document.indexOf('ЗАЯВЛЕНИЕ'));
expect(await zip.file('word/media/logo.png')!.async('base64')).toBe(INPUT.logo!.base64);
});
it('одно и то же заявление собирается байт в байт одинаково — ЭДО узнаёт повтор', async () => {
const [first, second] = await Promise.all([createVacationDocx(INPUT), createVacationDocx(INPUT)]);
expect(new Uint8Array(await readBlob(first))).toEqual(new Uint8Array(await readBlob(second)));
});
it('у каждой компании с печатной шапкой есть логотип для документа, размеры из PNG', () => {
for (const company of ['ТОО TANSU Construction', 'ТОО АСТЕЛ-К', 'ТОО СТ Global', 'ТОО KazPromService', 'ТОО QAZGEOMAX']) {
const logo = letterheadLogoFor(company);
expect(logo?.width).toBeGreaterThan(0);
expect(logo?.height).toBe(300);
}
expect(pngSize(INPUT.logo!.base64)).toEqual({ width: 212, height: 300 });
});
it('у компании без логотипа документ собирается без шапки', async () => {
expect(letterheadLogoFor('ТОО Монтажник-Т')).toBeUndefined();
const zip = await JSZip.loadAsync(await readBlob(await createVacationDocx({ ...INPUT, logo: undefined })));
expect(Object.keys(zip.files).sort()).toEqual(['[Content_Types].xml', '_rels/.rels', 'word/document.xml']);
expect(await zip.file('word/document.xml')!.async('string')).not.toContain('rIdLogo');
});
});
@@ -0,0 +1,191 @@
import JSZip from 'jszip';
import type { ILetterheadLogo } from '../../shared/letterheadLogo';
/** Данные заявления на отпуск. Всё, что идёт в текст, уже подготовлено вызывающим. */
export interface IVacationDocumentInput {
/** Организация, на которую пишется заявление («ТОО TANSU Construction»). */
companyName: string;
/** Должность заявителя; пусто — строка «должность» не выводится. */
position: string;
/** Ф.И.О. заявителя. */
employeeName: string;
/** Начало отпуска, ISO `YYYY-MM-DD`. */
startDate: string;
/** Сколько календарных дней длится отпуск. Конца нет: праздники и выходные считает кадровая служба. */
daysCount: number;
/** Ф.И.О. замещающего; пусто — абзац о замещении не выводится. */
substituteName: string;
/** Номер заявки в портале. */
requestCode: string;
/** Дата составления заявления, `дд.мм.гггг`. */
documentDate: string;
/** Логотип компании — брендированная шапка документа; нет у компании логотипа — без шапки. */
logo?: ILetterheadLogo;
}
export const DOCX_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
/** Экранирование для XML: имена и должности приходят из справочников и могут содержать `&`, `<`, кавычки. */
export function escapeXml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
type Align = 'left' | 'right' | 'center' | 'both';
interface IParagraph {
text: string;
align?: Align;
bold?: boolean;
/** Отступ после абзаца, twips (20 twips = 1 пт). */
after?: number;
/** Отступ первой строки, twips. */
firstLine?: number;
}
// Times New Roman 14 пт — привычный вид официального заявления. Размер в «половинках» пункта.
const FONT = 'Times New Roman';
const FONT_SIZE_HALF_POINTS = 28;
function paragraph({ text, align = 'left', bold = false, after = 0, firstLine = 0 }: IParagraph): string {
const indent = firstLine > 0 ? `<w:ind w:firstLine="${firstLine}"/>` : '';
const run =
`<w:rPr><w:rFonts w:ascii="${FONT}" w:hAnsi="${FONT}" w:cs="${FONT}"/>` +
`${bold ? '<w:b/>' : ''}<w:sz w:val="${FONT_SIZE_HALF_POINTS}"/><w:szCs w:val="${FONT_SIZE_HALF_POINTS}"/></w:rPr>`;
return (
`<w:p><w:pPr><w:spacing w:after="${after}" w:line="276" w:lineRule="auto"/>${indent}<w:jc w:val="${align}"/></w:pPr>` +
`<w:r>${run}<w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r></w:p>`
);
}
/** Абзацы заявления. Формулировка — рабочий вариант, финальный текст утверждает кадровая служба. */
export function buildVacationParagraphs(input: IVacationDocumentInput): IParagraph[] {
const paragraphs: IParagraph[] = [
// Шапка «кому/от кого»: должность в именительном падеже, поэтому без предлога «от».
{ text: input.companyName, align: 'right' },
...(input.position ? [{ text: input.position, align: 'right' as const }] : []),
{ text: input.employeeName, align: 'right', after: 480 },
{ text: 'ЗАЯВЛЕНИЕ', align: 'center', bold: true, after: 360 },
{
text:
`Прошу предоставить мне ежегодный оплачиваемый трудовой отпуск продолжительностью ${input.daysCount} ` +
`календарных дней, начиная с ${input.startDate}.`,
align: 'both',
firstLine: 709,
after: 240
}
];
if (input.substituteName) {
paragraphs.push({
text: `На период моего отсутствия исполнение обязанностей прошу возложить на ${input.substituteName}.`,
align: 'both',
firstLine: 709,
after: 240
});
}
paragraphs.push(
{ text: `Дата: ${input.documentDate}`, after: 120 },
{ text: `Заявка в портале №${input.requestCode}`, after: 360 },
{ text: `${input.employeeName} ____________` }
);
return paragraphs;
}
/** Логотип в шапке — высота 2,5 см, ширина по пропорции картинки. EMU: 1 см = 360 000. */
const LOGO_HEIGHT_EMU = 900000;
const LOGO_REL_ID = 'rIdLogo';
function logoParagraph(logo: ILetterheadLogo): string {
const cy = LOGO_HEIGHT_EMU;
const cx = Math.round((cy * logo.width) / logo.height);
return (
'<w:p><w:pPr><w:spacing w:after="360"/><w:jc w:val="left"/></w:pPr><w:r><w:drawing>' +
'<wp:inline distT="0" distB="0" distL="0" distR="0">' +
`<wp:extent cx="${cx}" cy="${cy}"/><wp:docPr id="1" name="Логотип"/>` +
'<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic>' +
'<pic:nvPicPr><pic:cNvPr id="0" name="logo.png"/><pic:cNvPicPr/></pic:nvPicPr>' +
`<pic:blipFill><a:blip r:embed="${LOGO_REL_ID}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>` +
`<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm>` +
'<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr>' +
'</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>'
);
}
/** Тело `word/document.xml`. Отдельно от упаковки в zip — чтобы проверять разметку без архива. */
export function buildDocumentXml(input: IVacationDocumentInput): string {
const body = (input.logo ? logoParagraph(input.logo) : '') + buildVacationParagraphs(input).map(paragraph).join('');
// A4, поля 2 см сверху/снизу/слева и 1,5 см справа — как у канцелярских бланков.
const section =
'<w:sectPr><w:pgSz w:w="11906" w:h="16838"/>' +
'<w:pgMar w:top="1134" w:right="850" w:bottom="1134" w:left="1701" w:header="709" w:footer="709" w:gutter="0"/></w:sectPr>';
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"' +
' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"' +
' xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"' +
' xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"' +
' xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">' +
`<w:body>${body}${section}</w:body></w:document>`
);
}
const CONTENT_TYPES_XML =
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
'<Default Extension="xml" ContentType="application/xml"/>' +
'<Default Extension="png" ContentType="image/png"/>' +
'<Override PartName="/word/document.xml" ' +
'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' +
'</Types>';
const ROOT_RELS_XML =
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
'<Relationship Id="rId1" ' +
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" ' +
'Target="word/document.xml"/></Relationships>';
/**
* Собирает заявление как DOCX. Файл строится прямо в браузере — отдельного сервиса
* документов нет, а ЭДО сам переводит DOCX в PDF перед подписанием.
*/
const DOCUMENT_RELS_XML =
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
`<Relationship Id="${LOGO_REL_ID}" ` +
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" ' +
'Target="media/logo.png"/></Relationships>';
const FIXED_ZIP_DATE = new Date(Date.UTC(2020, 0, 1));
export async function createVacationDocx(input: IVacationDocumentInput): Promise<Blob> {
const zip = new JSZip();
// Без записей каталогов (_rels/, word/): в файлах, собранных Word, их нет, и строгие
// читатели пакета (в том числе конвертер ЭДО) не должны на них спотыкаться.
// Дата записей в архиве фиксирована: иначе JSZip ставит «сейчас», и одно и то же заявление при
// повторной отправке давало бы другой файл — ЭДО не узнал бы повтор и завёл бы второй документ.
const options = { createFolders: false, date: FIXED_ZIP_DATE };
zip.file('[Content_Types].xml', CONTENT_TYPES_XML, options);
zip.file('_rels/.rels', ROOT_RELS_XML, options);
zip.file('word/document.xml', buildDocumentXml(input), options);
if (input.logo) {
zip.file('word/_rels/document.xml.rels', DOCUMENT_RELS_XML, options);
zip.file('word/media/logo.png', input.logo.base64, { ...options, base64: true });
}
return zip.generateAsync({ type: 'blob', mimeType: DOCX_CONTENT_TYPE, compression: 'DEFLATE' });
}
/** Имя файла для ЭДО: по нему человек узнает документ в письме и кабинете — и отличит компании. */
export function vacationFileName(employeeName: string, companyName: string, documentDate: string): string {
// Недопустимые в имени файла символы заменяем, а не удаляем: ФИО и компания остаются читаемыми.
const safe = (text: string): string => text.replace(/[\\/:*?"<>|]/g, ' ').replace(/\s+/g, ' ').trim();
return `Заявление на отпуск — ${safe(employeeName)} — ${safe(companyName)} — ${documentDate}.docx`;
}
@@ -17,10 +17,12 @@ import {
useRequestSubmit
} from '../../shared/ui';
import {
composeRemoveRowRule,
ApprovalMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
canEditExecutors,
getPlannedMatrix,
resolveWriteListId,
notifyActiveStep,
@@ -212,7 +214,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
const executors = useMatrixEditor(
loadExecutors,
`${userEmail}-${editId ?? 'new'}-${data.DepartmentId}`,
false,
canEditExecutors(context, readOnly),
!!editId || !!requestTypeId,
(login) => approval.getJobTitle(login)
);
@@ -391,14 +393,20 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
</CollapseSection>
)}
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
<ApprovalMatrix
editor={matrix}
search={(q) => approval.searchUsers(q)}
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
/>
{!executors.loading && executors.rows.length > 0 && (
{!executors.loading && (executors.editable || executors.rows.length > 0) && (
<ApprovalMatrix
editor={executors}
search={(q) => approval.searchUsers(q)}
title="Исполнение (после согласования)"
roleLabel="Исполнитель"
canRemoveRow={executors.editable ? () => true : undefined}
pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…"
emptyText="Исполнители не заданы по маршруту."
/>
)}