Feature/vacation edo signing #23
@@ -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)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user