Feature/vacation edo signing (#23)
Tests / frontend-test (push) Successful in 1m24s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/23
This commit was merged in pull request #23.
This commit is contained in:
2026-10-05 08:53:18 +00:00
parent f8ab2a1d71
commit 637e51d7f9
85 changed files with 2152 additions and 171 deletions
@@ -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;
}