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 {
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;
}