feat: таблица исполнителей во всех формах — при заполнении и просмотре, если они есть в маршруте

Общие useFormExecutors/useViewExecutors и ExecutorsMatrix вместо копирования
в каждой форме. Подключены в формах без фазы исполнения: ConcreteRequest,
Dismissal, EmployeeTransfer, ServiceRequest, Ticket1C, UnpaidLeave, Vacation.
Исполнители теперь пишутся в маршрут при подаче; таблица согласующих
грузит только фазу согласования, чтобы не смешиваться с исполнением.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-10-02 12:58:34 +05:00
parent 80caa59301
commit 3337aeda6c
24 changed files with 464 additions and 54 deletions
@@ -15,6 +15,7 @@ import {
import {
composeRemoveRowRule,
ApprovalMatrix,
ExecutorsMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
@@ -25,7 +26,11 @@ import {
REQUEST_STATUS,
isEditableStatus,
IApproverToWrite,
unassignedStepMessage
unassignedStepMessage,
useFormExecutors,
approvalPhaseRows,
executorsToWrite,
initialSubmitStatus
} from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types';
@@ -171,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]
);
@@ -183,6 +188,17 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
(login) => approval.getJobTitle(login)
);
// Исполнители — из маршрута админки (заявитель их не правит); те же параметры
// резолва, что и у согласующих.
const executors = useFormExecutors({
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 => {
@@ -224,6 +240,7 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
rtId,
data,
toApprovers(matrix.rows),
executorsToWrite(executors.rows),
status,
writeListId
);
@@ -238,7 +255,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),
@@ -260,11 +277,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,
@@ -331,6 +358,8 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
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;
}
@@ -15,6 +15,7 @@ import {
import {
composeRemoveRowRule,
ApprovalMatrix,
ExecutorsMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
@@ -25,7 +26,11 @@ import {
isEditableStatus,
IApproverToWrite,
unassignedStepMessage,
resolveWriteListId
resolveWriteListId,
approvalPhaseRows,
executorsToWrite,
initialSubmitStatus,
useFormExecutors
} from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types';
@@ -159,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) }
]),
@@ -173,6 +178,17 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
(login) => approval.getJobTitle(login)
);
// Исполнители самого заявления (фаза «Исполнение» маршрута из админки) — по
// тому же сотруднику и тем же fieldValues, что и согласующие выше.
const executors = useFormExecutors({
context,
approval,
requestTypeId,
userEmail: data.Employee,
editId,
fieldValues: [{ fieldName: 'Position', value: primaryPosition(data.Organization) }]
});
// Убрать компанию из заявления (совместитель, реально не увольняется из
// ВСЕХ) — см. OrganizationsTable. Снимает компанию из data.Organization И, по
// возможности, соответствующего согласующего (директора/должностного
@@ -245,6 +261,7 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
rtId,
data,
toApprovers(matrix.rows),
executorsToWrite(executors.rows),
status,
writeListId
);
@@ -259,7 +276,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),
@@ -281,11 +298,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,
@@ -348,6 +381,8 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
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;
}
@@ -18,6 +18,11 @@ import {
ApprovalHistory,
StatusBadge,
useMatrixEditor,
useFormExecutors,
ExecutorsMatrix,
approvalPhaseRows,
executorsToWrite,
initialSubmitStatus,
getPlannedMatrix,
resolveWriteListId,
notifyActiveStep,
@@ -155,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]
);
@@ -167,6 +172,16 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
(login) => approval.getJobTitle(login)
);
// Исполнители — по тому же сотруднику, что и маршрут согласования. Только чтение:
// их назначает маршрут из админки, не заявитель.
const executors = useFormExecutors({
context,
approval,
requestTypeId,
userEmail: data.Employee,
editId
});
const isValid = !!(
data.Employee &&
data.Position.trim() &&
@@ -213,6 +228,7 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
rtId,
data,
toApprovers(matrix.rows),
executorsToWrite(executors.rows),
status,
writeListId
);
@@ -227,7 +243,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),
@@ -249,11 +265,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 };
@@ -310,6 +342,8 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
/>
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
@@ -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;
}
@@ -25,7 +25,12 @@ import {
isEditableStatus,
IApproverToWrite,
unassignedStepMessage,
resolveWriteListId
resolveWriteListId,
useFormExecutors,
ExecutorsMatrix,
approvalPhaseRows,
executorsToWrite,
initialSubmitStatus
} from '../../shared/approval';
import {
uploadAttachments,
@@ -181,13 +186,23 @@ 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({
context,
approval,
requestTypeId,
userEmail: matrixReady ? userEmail : '',
editId,
projectId
});
const hasCosts = hasAnyCostItem(data.Costs);
const isValid =
@@ -273,7 +288,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 +309,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 +329,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,
@@ -390,6 +420,8 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
waitingText="Укажите заявителя и проект, чтобы построить маршрут согласования."
/>
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
@@ -20,6 +20,9 @@ import {
ApprovalMatrix,
ApprovalHistory,
useMatrixEditor,
useViewExecutors,
ExecutorsMatrix,
approvalPhaseRows,
notifyActiveStep,
useRequestTypeId,
isEditableStatus
@@ -77,10 +80,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;
@@ -180,6 +184,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;
}
@@ -29,7 +29,12 @@ import {
REQUEST_STATUS,
isEditableStatus,
IApproverToWrite,
unassignedStepMessage
unassignedStepMessage,
approvalPhaseRows,
executorsToWrite,
useFormExecutors,
ExecutorsMatrix,
initialSubmitStatus
} from '../../shared/approval';
import {
uploadAttachments,
@@ -172,7 +177,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
const loadMatrix = useCallback(
() =>
editId
? approval.getMatrixForRequest(editId)
? approval.getMatrixForRequest(editId).then(approvalPhaseRows)
: getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, [
{ fieldName: 'WorkType', value: data.WorkType },
{ fieldName: 'Department', value: data.Department }
@@ -186,6 +191,20 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
!!editId || (!!data.WorkType && !!requestTypeId),
(login) => approval.getJobTitle(login)
);
// Исполнители (фаза «Исполнение») резолвятся с теми же fieldValues, что и
// согласующие: условные шаги маршрута зависят от вида работ и отдела.
const executors = useFormExecutors({
context,
approval,
requestTypeId,
userEmail,
editId,
departmentId: null,
fieldValues: [
{ fieldName: 'WorkType', value: data.WorkType },
{ fieldName: 'Department', value: data.Department }
]
});
// Должность нового «Руководителя» — approval-route её не знает (резолвил шаг
// на ДРУГОГО человека и отдал его должность), поэтому подтягиваем сами по
// e-mail из поля (SPS-JobTitle, тот же источник, что и у обычных согласующих,
@@ -278,7 +297,13 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
// работ (раньше в этом случае заявка сразу становилась «Согласовано» в
// обход согласования; теперь это просто «На согласовании» с пустой
// матрицей — сигнал администратору маршрутов донастроить условие).
const finalStatus = intendedStatus === REQUEST_STATUS.draft ? REQUEST_STATUS.draft : REQUEST_STATUS.onApproval;
// Есть исполнители — статус отправки по initialSubmitStatus (без согласующих
// сразу «Выполнение»); иначе, как раньше, «На согласовании» (в т.ч. при пустом маршруте).
const submitStatus =
executors.rows.length > 0
? initialSubmitStatus(matrixRows.length, executors.rows.length)
: REQUEST_STATUS.onApproval;
const finalStatus = intendedStatus === REQUEST_STATUS.draft ? REQUEST_STATUS.draft : submitStatus;
// requestId уже задан → редактируем существующую заявку, пишем в ту же
// партицию (editListId). Иначе — создаём новую, и партицию для записи
@@ -293,6 +318,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
requestId,
data,
toApprovers(matrixRows),
executorsToWrite(executors.rows),
finalStatus,
writeListId
);
@@ -315,7 +341,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
// Передача в Planner теперь происходит только после реального решения
// согласующего (см. Ticket1CView.handoffToPlanner) — здесь заявка всегда
// отправляется на согласование, минуя Planner напрямую.
if (finalStatus === REQUEST_STATUS.onApproval) {
if (finalStatus === REQUEST_STATUS.onApproval || finalStatus === REQUEST_STATUS.execution) {
// Имена списков — той же партиции, куда фактически записали (writeListId),
// не обязательно текущей на момент рендера формы: поток ищет строку
// маршрута именно в НЕЙ.
@@ -345,6 +371,11 @@ const Ticket1CForm: 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;
};
@@ -424,6 +455,8 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
emptyText="Для этого вида работ согласование не требуется — заявка сразу поступит в отдел 1С."
/>
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
@@ -25,7 +25,10 @@ import {
notifyActiveStep,
useRequestTypeId,
isEditableStatus,
REQUEST_STATUS
REQUEST_STATUS,
approvalPhaseRows,
useViewExecutors,
ExecutorsMatrix
} from '../../shared/approval';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { friendlyErrorMessage } from '../../shared/diagnostics';
@@ -112,10 +115,11 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
// decisionSeq входит в reloadKey: после решения строки маршрута меняются на
// сервере, поэтому счётчик инкрементируется и useMatrixEditor перечитывает матрицу.
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;
@@ -246,6 +250,8 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
search={(q) => approval.searchUsers(q)}
emptyText="Согласование не требовалось для этого вида работ."
/>
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
</div>
{/* История согласования: маршрут пересохраняется патчем (см.
@@ -32,10 +32,10 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
export type ISaveResult = ISaveItemResult;
/**
* Сохранить заявку в отдел 1С: содержимое + маршрут согласования.
* Сохранить заявку в отдел 1С: содержимое + маршрут (согласующие и исполнители).
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Фазы исполнения нет: после согласования заявка уходит в Planner, а не исполнителю
* из матрицы. Возвращает Id заявки (+ код при создании).
* executors — исполнители из маршрута админки (фаза «Исполнение»), могут быть пустыми:
* тогда после согласования заявка уходит в Planner. Возвращает Id заявки (+ код при создании).
*
* listId — партиция, в которую фактически пишем: при редактировании — партиция
* ОБНОВЛЯЕМОЙ заявки (см. loadTicketRequest), при создании — партиция,
@@ -49,13 +49,14 @@ export async function saveTicketRequest(
existingId: number | undefined,
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, TICKET1C_CODE_PREFIX, item);
await writeRoute(ticket1cApprovalService(listId), result.id, approvers, [], status);
await writeRoute(ticket1cApprovalService(listId), result.id, approvers, executors, status);
return result;
}
@@ -15,9 +15,14 @@ import {
import {
composeRemoveRowRule,
ApprovalMatrix,
ExecutorsMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
useFormExecutors,
approvalPhaseRows,
executorsToWrite,
initialSubmitStatus,
getPlannedMatrix,
notifyActiveStep,
useRequestTypeId,
@@ -156,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]
);
@@ -168,6 +173,15 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
(login) => approval.getJobTitle(login)
);
// Исполнители фазы «Исполнение» — по маршруту из админки, только чтение.
const executors = useFormExecutors({
context,
approval,
requestTypeId,
userEmail: data.Employee,
editId
});
// Даты корректны, если конец не раньше начала (обе обязательны).
const datesValid = !!(data.StartDate && data.EndDate && data.EndDate >= data.StartDate);
// Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала
@@ -225,6 +239,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
rtId,
data,
toApprovers(matrix.rows),
executorsToWrite(executors.rows),
status,
writeListId
);
@@ -239,7 +254,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),
@@ -261,11 +276,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 };
@@ -322,6 +348,8 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
/>
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
@@ -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 (комментарий редактируемый). */}
@@ -62,7 +62,7 @@ export type ISaveResult = ISaveItemResult;
/**
* Сохранить заявку без содержания: содержимое + маршрут согласования.
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей.
* executors — исполнители фазы «Исполнение» из маршрута (пусто, если в маршруте их нет).
* Возвращает Id заявки (+ код при создании).
*
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadUnpaidLeaveRequest). Имеет смысл
@@ -75,6 +75,7 @@ export async function saveUnpaidLeaveRequest(
requestTypeId: number,
data: IFormData,
approvers: IApproverToWrite[],
executors: IApproverToWrite[],
status: string,
listId?: string
): Promise<ISaveResult> {
@@ -84,6 +85,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;
}
@@ -18,6 +18,11 @@ import {
ApprovalHistory,
StatusBadge,
useMatrixEditor,
useFormExecutors,
ExecutorsMatrix,
approvalPhaseRows,
executorsToWrite,
initialSubmitStatus,
getPlannedMatrix,
resolveWriteListId,
notifyActiveStep,
@@ -157,7 +162,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]
);
@@ -169,6 +174,16 @@ const VacationForm: React.FC<IRequestFormProps> = ({
(login) => approval.getJobTitle(login)
);
// Исполнители из маршрута админки (фаза «Исполнение»): только чтение, таблица
// и запись в заявку — когда они в маршруте есть.
const executors = useFormExecutors({
context,
approval,
requestTypeId,
userEmail: data.Employee,
editId
});
const daysNum = Number(data.DaysCount);
// Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала
// отпуска — календарь (min у input type=date, см. RequestSection) не даёт
@@ -223,6 +238,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
rtId,
data,
toApprovers(matrix.rows),
executorsToWrite(executors.rows),
status,
writeListId
);
@@ -238,7 +254,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),
@@ -260,11 +276,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 };
@@ -321,6 +353,8 @@ const VacationForm: React.FC<IRequestFormProps> = ({
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
/>
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
@@ -10,6 +10,9 @@ import {
ApprovalMatrix,
ApprovalHistory,
useMatrixEditor,
useViewExecutors,
ExecutorsMatrix,
approvalPhaseRows,
notifyActiveStep,
useRequestTypeId
} from '../../shared/approval';
@@ -78,10 +81,11 @@ 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]);
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;
@@ -180,6 +184,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 (комментарий редактируемый). */}
@@ -54,7 +54,7 @@ export type ISaveResult = ISaveItemResult;
/**
* Сохранить заявку на отпуск: содержимое + маршрут согласования.
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Фазы исполнения у отпуска нет — writeRoute вызывается с пустым списком исполнителей.
* executors — исполнители фазы «Исполнение» из маршрута админки (пусто, если в маршруте их нет).
* Возвращает Id заявки (+ код при создании).
*
* listId — партиция, в которую фактически пишем: при редактировании — партиция
@@ -70,6 +70,7 @@ export async function saveVacationRequest(
requestTypeId: number,
data: IFormData,
approvers: IApproverToWrite[],
executors: IApproverToWrite[],
status: string,
listId?: string
): Promise<ISaveResult> {
@@ -79,6 +80,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;
}
@@ -63,11 +63,19 @@ export { notifyActiveStep } from './notify.service';
export type { INotifyTarget } from './notify.service';
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
export type { IUseMatrixEditor } from './useMatrixEditor';
export {
approvalPhaseRows,
executionPhaseRows,
executorsToWrite,
useFormExecutors,
useViewExecutors
} from './useRouteExecutors';
export { resolveApproverOrgInfo } from './approverOrgInfo';
export type { IApproverOrgInfo } from './approverOrgInfo';
export { canEditRoute, composeRemoveRowRule } from './routeEditRights';
export { canEditAllFields } from './fullEditRights';
export { ApprovalMatrix } from './ui/ApprovalMatrix';
export { ExecutorsMatrix } from './ui/ExecutorsMatrix';
export { ApprovalHistory } from './history/ApprovalHistory';
export { ApprovalHistoryFeed } from './history/ApprovalHistoryFeed';
export { HISTORY_TEXT } from './history/historyTexts';
@@ -0,0 +1,28 @@
import * as React from 'react';
import { ISiteUser } from '../models/ISiteUser';
import { IUseMatrixEditor } from '../useMatrixEditor';
import { ApprovalMatrix } from './ApprovalMatrix';
interface IExecutorsMatrixProps {
editor: IUseMatrixEditor;
search: (query: string) => Promise<ISiteUser[]>;
/** Заголовок; по умолчанию «Исполнение». */
title?: string;
}
/**
* Таблица исполнителей — рисуется только когда в маршруте из админки они есть:
* у форм без фазы исполнения блока нет вовсе.
*/
export const ExecutorsMatrix: React.FC<IExecutorsMatrixProps> = ({ editor, search, title = 'Исполнение' }) => {
if (editor.loading || editor.rows.length === 0) return null;
return (
<ApprovalMatrix
editor={editor}
search={search}
title={title}
roleLabel="Исполнитель"
emptyText="Исполнители не назначены."
/>
);
};
@@ -0,0 +1,102 @@
import { useCallback } from 'react';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { ApprovalListService, IApproverToWrite } from './approvalList.service';
import { getPlannedMatrix } from './approvalSubmission.service';
import { resolveApproverOrgInfo } from './approverOrgInfo';
import { IResolveFieldValue } from './models/IApprovalRouteApi';
import { IMatrixRow } from './models/IMatrixRow';
import { APPROVAL_PHASE } from './models/statuses';
import { IUseMatrixEditor, useMatrixEditor } from './useMatrixEditor';
/** Строки фазы согласования (всё, что не «Исполнение») — для таблицы согласующих. */
export const approvalPhaseRows = (rows: IMatrixRow[]): IMatrixRow[] =>
rows.filter((r) => r.phase !== APPROVAL_PHASE.execution);
/** Строки фазы исполнения — для таблицы исполнителей. */
export const executionPhaseRows = (rows: IMatrixRow[]): IMatrixRow[] =>
rows.filter((r) => r.phase === APPROVAL_PHASE.execution);
/**
* Строки матрицы исполнителей → то, что пишет writeRoute. Номер шага
* нормализуется к 1..N внутри фазы: у уже записанных строк он сквозной (со
* смещением на согласующих), а writeRoute смещает фазу сам.
*/
export const executorsToWrite = (rows: IMatrixRow[]): IApproverToWrite[] => {
const base = rows.reduce((min, r) => Math.min(min, r.order), Number.MAX_SAFE_INTEGER);
return rows.map((r) => ({
email: r.email,
position: r.position,
allowReassign: r.allowReassign,
order: r.order - base + 1,
slaHours: r.slaHours,
askOverdueReason: r.askOverdueReason,
autoApprove: r.autoApprove
}));
};
interface IFormExecutorsSource {
context: WebPartContext;
approval: ApprovalListService;
requestTypeId: number | undefined;
/** E-mail, по которому резолвится маршрут (инициатор/выбранный сотрудник). */
userEmail: string;
/** Редактируемая заявка — исполнители читаются из её строк, а не планируются заново. */
editId: number | undefined;
/** Те же параметры, что форма отдаёт getPlannedMatrix для согласующих. */
departmentId?: string | null; // eslint-disable-line @rushstack/no-new-null -- транзитом в resolveRoute
fieldValues?: IResolveFieldValue[];
projectId?: string | null; // eslint-disable-line @rushstack/no-new-null -- транзитом в resolveRoute
/** Дополнительный ключ перезагрузки (значение поля, влияющее на маршрут). */
reloadSuffix?: string;
}
/**
* Исполнители формы заполнения (фаза «Исполнение»): планируемые по маршруту из
* админки для новой заявки, из записанных строк — для черновика/доработки.
* Только чтение: исполнителя назначает маршрут, не заявитель.
*/
export function useFormExecutors(source: IFormExecutorsSource): IUseMatrixEditor {
const { context, approval, requestTypeId, userEmail, editId, departmentId, fieldValues, projectId, reloadSuffix = '' } = source;
const fieldValuesKey = JSON.stringify(fieldValues ?? []);
const load = useCallback(
() =>
editId
? approval.getMatrixForRequest(editId).then(executionPhaseRows)
: getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Execution', departmentId, fieldValues, projectId),
// fieldValues сравниваем по содержимому: форма пересоздаёт массив на каждый рендер.
// eslint-disable-next-line react-hooks/exhaustive-deps
[context, approval, editId, requestTypeId, userEmail, departmentId, fieldValuesKey, projectId]
);
return useMatrixEditor(
load,
`x-${userEmail}-${editId ?? 'new'}-${departmentId ?? ''}-${projectId ?? ''}-${fieldValuesKey}-${reloadSuffix}`,
false,
!!editId || (!!userEmail && !!requestTypeId),
(login) => approval.getJobTitle(login),
resolveApproverOrgInfo(context)
);
}
/**
* Исполнители вьюхи просмотра — из фактических строк заявки. decisionSeq в
* ключе: после решения/замены строки на сервере меняются — перечитываем.
*/
export function useViewExecutors(
context: WebPartContext,
approval: ApprovalListService,
itemId: number,
decisionSeq: number
): IUseMatrixEditor {
const load = useCallback(
() => approval.getMatrixForRequest(itemId).then(executionPhaseRows),
[approval, itemId]
);
return useMatrixEditor(
load,
`e-${itemId}-${decisionSeq}`,
false,
true,
(l) => approval.getJobTitle(l),
resolveApproverOrgInfo(context)
);
}