Files
tnsu-approval-route/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx
T
2026-08-12 15:12:11 +05:00

660 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as React from 'react';
import styles from './ManageRequestsScreen.module.scss';
import { ManageRequestsApproverModal } from './ManageRequestsApproverModal';
import { ManageRequestsPhaseTable } from './ManageRequestsPhaseTable';
import { ManageRequestsSlaModal } from './ManageRequestsSlaModal';
import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary';
import { RequestManageService } from '../../services/requestManageService';
import {
APPROVAL_PHASE,
ManagedApprovalRow,
ManagedRequestMatrix,
ManagedRequestSummary,
REQUEST_STATUS,
isFullyClosedRequestStatus
} from '../../models';
import { texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes';
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { findEmployeeByEmail } from '../../utils/employee';
import { reportError } from '../../utils/errors';
import { markParallel, withOrdersFrom } from '../../utils/orderBlocks';
import { IManageRequestRouteParams, replaceRoute } from '../../routing';
import {
Alert,
AsyncSearchableSelect,
Badge,
BadgeVariant,
Breadcrumbs,
ConfirmDialog,
Field,
FormGrid,
PageHeader,
Panel,
PrimaryCell,
Select,
Toast
} from '../ui';
export interface IManageRequestsScreenProps {
api: ApprovalRouteApi;
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
dictionary?: PortalDictionaryClient;
requestManage: RequestManageService;
/** Заполнение из URL (глубокая ссылка на конкретную заявку) — есть, только
* если в адресе заданы aamtype/aamreq (см. routing.ts). */
route?: IManageRequestRouteParams;
}
// Цвет плашки статуса — те же группы смысла, что и в request-portal
// (StatusBadge.tsx): решено положительно/отрицательно/ожидает/черновик.
function statusVariant(status: string): BadgeVariant {
if (status === 'Согласовано' || status === 'Выполнено' || status === 'Закрыта') return 'ok';
if (status === 'Отклонено') return 'red';
if (status === 'Возвращена на доработку') return 'gold';
if (status === 'Черновик') return 'neutral';
return 'blue'; // Ждёт очереди / На согласовании / На исполнении / Выполнение
}
// Новый шаг всегда встаёт ПОСЛЕ всех существующих строк фазы — в т.ч. после уже
// решённых. Перестановка (ManageRequestsPhaseTable) двигает существующие шаги,
// но НИКОГДА не пускает их раньше последнего уже решённого блока — см.
// utils/orderBlocks.ts. Вместе эти два правила и дают гарантию «нельзя
// поставить согласующего выше того, кто уже одобрил заявку».
function nextOrder(phaseRows: ManagedApprovalRow[]): number {
return phaseRows.length > 0 ? Math.max(...phaseRows.map(r => r.order)) + 1 : 1;
}
function patchRow(matrix: ManagedRequestMatrix, rowId: number, patch: Partial<ManagedApprovalRow>): ManagedRequestMatrix {
return { ...matrix, rows: matrix.rows.map(r => (r.id === rowId ? { ...r, ...patch } : r)) };
}
// После удаления шага у ОСТАВШИХСЯ шагов той же фазы order пересчитывается
// заново по порядку (1..N), а не остаётся с дыркой на месте удалённого — та же
// логика перестановки, что и в ManageRequestsPhaseTable (withOrdersFrom),
// только вызывается отсюда, т.к. удаление живёт на уровне экрана. Renumber'ит
// ВСЮ фазу, включая уже решённые строки: их order на активацию очереди больше
// не влияет (activateNextQueued смотрит только на queued/onApproval/
// onExecution), так что перенумеровать их безопасно — а без этого дырка от
// удалённого решённого шага так и осталась бы видна в колонке «Шаг».
function renumberPhase(phaseRows: ManagedApprovalRow[]): Array<{ id: number; order: number }> {
const sorted = [...phaseRows].sort((a, b) => a.order - b.order);
const renumbered = withOrdersFrom(markParallel(sorted), 0);
return renumbered
.filter((r, i) => r.order !== sorted[i].order)
.map(r => ({ id: r.id, order: r.order }));
}
// Строки в одном из этих статусов ещё «в игре» — решение по ним не принято.
// approved/executed/rejected/returned сюда не входят: это уже принятые решения
// (в т.ч. «Возвращена на доработку» — согласующий своё слово сказал, просто мы
// разрешили его потом переназначить; в очереди на активацию он не участвует).
const PENDING_STATUSES: string[] = [REQUEST_STATUS.queued, REQUEST_STATUS.onApproval, REQUEST_STATUS.onExecution];
// Пересчитывает «чья сейчас очередь» в фазе — 1:1 activateNextQueued из
// request-portal (shared/approval/approvalList.service.ts): среди ещё не
// решённых строк у той, что с МИНИМАЛЬНЫМ order, выставляется активный статус
// фазы («На согласовании»/«На исполнении»), у всех остальных — «Ждёт очереди».
// Несколько строк с одинаковым order (параллельный шаг) активируются вместе —
// в этом и смысл параллельности. Вызывается после ЛЮБОГО изменения, которое
// могло поменять, кто в фазе «первый»: перестановка, удаление, добавление.
function computeActivationUpdates(phaseRows: ManagedApprovalRow[], phase: string): Array<{ id: number; status: string }> {
const activeStatus = phase === APPROVAL_PHASE.execution ? REQUEST_STATUS.onExecution : REQUEST_STATUS.onApproval;
const pending = phaseRows.filter(r => PENDING_STATUSES.indexOf(r.status) !== -1);
if (pending.length === 0) return [];
const minOrder = Math.min(...pending.map(r => r.order));
const updates: Array<{ id: number; status: string }> = [];
pending.forEach(r => {
const desired = r.order === minOrder ? activeStatus : REQUEST_STATUS.queued;
if (r.status !== desired) updates.push({ id: r.id, status: desired });
});
return updates;
}
// Сливает изменения order и status по одному и тому же id в один объект — иначе
// пришлось бы отправлять на одну и ту же строку два отдельных PATCH-запроса.
function mergeRowUpdates(
orderUpdates: Array<{ id: number; order: number }>,
statusUpdates: Array<{ id: number; status: string }>
): Array<{ id: number; order?: number; status?: string }> {
const byId = new Map<number, { id: number; order?: number; status?: string }>();
orderUpdates.forEach(u => byId.set(u.id, { ...(byId.get(u.id) || { id: u.id }), order: u.order }));
statusUpdates.forEach(u => byId.set(u.id, { ...(byId.get(u.id) || { id: u.id }), status: u.status }));
return Array.from(byId.values());
}
// Что выбрано в комбобоксах «Тип заявки»/«Заявка» — переживает перезагрузку
// страницы (localStorage), чтобы не искать одну и ту же заявку заново после
// каждого F5. selectedRequest хранится целиком (не только id), чтобы сразу
// показать подпись в AsyncSearchableSelect без похода в API на восстановление.
interface IPersistedSelection {
requestTypeId: string;
selectedRequest: ManagedRequestSummary | undefined;
}
const EMPTY_SELECTION: IPersistedSelection = { requestTypeId: '', selectedRequest: undefined };
const SELECTION_STORAGE_KEY = 'approvalAdmin.manageRequestsSelection';
// Раз в 2 минуты тихо (без «Загрузка…» на весь экран) перечитываем матрицу
// открытой заявки — на неё мог повлиять кто-то другой (согласующий принял
// решение через request-portal), а вкладка могла быть открыта давно.
const AUTO_REFRESH_MS = 2 * 60 * 1000;
export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api, dictionary, requestManage, route }) => {
const requestTypes = useRequestTypes(api);
const employees = usePortalEmployees(dictionary);
const [selection, setSelection] = useLocalStorageState<IPersistedSelection>(SELECTION_STORAGE_KEY, EMPTY_SELECTION);
const { requestTypeId, selectedRequest } = selection;
// Открыли по глубокой ссылке (aamtype/aamreq в URL) — перекрывает то, что
// было в localStorage. Только на монтировании — см. тот же приём в
// ResolveScreen (route меняется при каждом собственном replaceRoute ниже).
// title/status заявки в URL не хранятся (их полностью определяет requestId) —
// ставим пустыми, реальные подтянутся, как только матрица загрузится (см.
// reloadMatrix ниже: там selection.selectedRequest патчится из matrix.request).
React.useEffect(() => {
if (!route) return;
setSelection({
requestTypeId: route.requestTypeId,
selectedRequest: { id: Number(route.requestId), title: '', status: '', created: '', authorName: '' }
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Держим URL синхронным с выбором — открытую заявку можно скопировать и
// прислать коллеге. replaceRoute, а не navigate: смена заявки — это правка
// состояния текущего экрана, а не переход на новую страницу с отдельным
// шагом в истории браузера. title/status заявки в URL не пишем — их выводит
// сам requestId, хранить производные данные в ссылке незачем.
React.useEffect(() => {
replaceRoute({
tab: 'manageRequests',
manage: selectedRequest ? { requestTypeId, requestId: String(selectedRequest.id) } : undefined
});
}, [requestTypeId, selectedRequest]);
const [matrix, setMatrix] = React.useState<ManagedRequestMatrix | undefined>();
const [matrixLoading, setMatrixLoading] = React.useState(false);
const [matrixError, setMatrixError] = React.useState<string | undefined>();
const [modal, setModal] = React.useState<ManagedApprovalRow | undefined>();
const [slaRow, setSlaRow] = React.useState<ManagedApprovalRow | undefined>();
const [deleteRow, setDeleteRow] = React.useState<ManagedApprovalRow | undefined>();
const [saveError, setSaveError] = React.useState<string | undefined>();
const [notifyResult, setNotifyResult] = React.useState<{ message: string; kind: 'ok' | 'err' } | undefined>();
const [notifyingRowId, setNotifyingRowId] = React.useState<number | undefined>();
// Отрицательные Id — «призрачные» строки: добавлены на клиенте, ответ сервера
// (и настоящий Id из SharePoint) ещё не долетел. Реальные Id списка всегда
// положительные, поэтому пересечься эти диапазоны не могут.
const nextGhostId = React.useRef(-1);
const requestType = requestTypes.items.filter(rt => rt.id === Number(requestTypeId))[0];
// Только типы с привязанным списком SharePoint — без list_id/site_url искать
// и читать заявки этого типа отсюда просто негде.
const manageableRequestTypes = requestTypes.items.filter(rt => rt.listId && rt.siteUrl);
const selectRequestType = (id: string): void => {
// Заявка выбрана для ДРУГОГО типа — сбрасываем, иначе комбобокс продолжит
// показывать заявку не того типа, что выбран сейчас.
setSelection({ requestTypeId: id, selectedRequest: undefined });
setMatrix(undefined);
setMatrixError(undefined);
};
const selectRequest = (item: ManagedRequestSummary | undefined): void =>
setSelection(prev => ({ ...prev, selectedRequest: item }));
const searchRequests = React.useCallback(
(query: string, page: number) => {
if (!requestType) return Promise.resolve({ items: [], hasMore: false });
return requestManage.searchRequests(requestType, query, page);
},
[requestManage, requestType]
);
// silent=true — фоновое обновление (после своего же действия, автообновление
// каждые 2 мин): матрица подменяется на новую БЕЗ показа полноэкранного
// «Загрузка…», чтобы клик по кнопке не выглядел как перезагрузка страницы.
// silent=false — только при выборе ДРУГОЙ заявки/типа: там показывать
// загрузку нормально, экран и так пуст.
const reloadMatrix = React.useCallback(
(silent = false) => {
if (!requestType || !selectedRequest) return;
if (!silent) setMatrixLoading(true);
setMatrixError(undefined);
requestManage
.getMatrix(requestType, selectedRequest.id)
.then(m => {
setMatrix(m);
// title/status в комбобоксе (а значит, и в localStorage) держим
// синхронными с сервером — особенно важно после захода по глубокой
// ссылке, где изначально они пустые (см. эффект-затравку выше).
//
// ВАЖНО: возвращаем ТОТ ЖЕ prev (без изменений), если title/status не
// отличаются — m.request это каждый раз НОВЫЙ объект (свежий парсинг
// ответа), и если писать его в состояние безусловно, selectedRequest
// получает новую ссылку на КАЖДОЙ загрузке, даже когда содержимое не
// поменялось. Это меняет зависимости reloadMatrix → пересоздаёт саму
// функцию → мониторящий её useEffect срабатывает заново → снова
// reloadMatrix() → снова новая ссылка — бесконечный цикл перезагрузки
// (именно так выглядела «неадекватно долгая загрузка»).
setSelection(prev => {
const sel = prev.selectedRequest;
if (!sel || sel.id !== m.request.id) return prev;
const same = sel.title === m.request.title && sel.status === m.request.status;
return same ? prev : { ...prev, selectedRequest: m.request };
});
})
.catch((e: Error) => setMatrixError(reportError(e, 'reloadMatrix: getMatrix')))
.finally(() => {
if (!silent) setMatrixLoading(false);
});
},
[requestManage, requestType, selectedRequest]
);
React.useEffect(() => {
setMatrix(undefined);
reloadMatrix();
}, [reloadMatrix]);
React.useEffect(() => {
const timer = window.setInterval(() => {
if (!document.hidden) reloadMatrix(true);
}, AUTO_REFRESH_MS);
return () => window.clearInterval(timer);
}, [reloadMatrix]);
// Удаление — строка убирается из матрицы СРАЗУ по подтверждению, не дожидаясь
// ответа сервера (запрос уходит фоном). Если он всё же упадёт — строку
// возвращаем обратно и показываем ошибку. useDeleteTarget тут не подходит:
// тот хук держит диалог открытым со спиннером до ответа сервера, а нужно
// ровно наоборот — закрыть диалог и убрать строку мгновенно.
const askDelete = (row: ManagedApprovalRow): void => setDeleteRow(row);
const cancelDelete = (): void => setDeleteRow(undefined);
const confirmDelete = (): void => {
if (!matrix || !deleteRow) return;
const row = deleteRow;
setDeleteRow(undefined);
setSaveError(undefined);
const previous = matrix;
const remainingPhaseRows = matrix.rows.filter(r => r.phase === row.phase && r.id !== row.id);
const renumberUpdates = renumberPhase(remainingPhaseRows);
const orderById = new Map(renumberUpdates.map(u => [u.id, u.order]));
const renumberedPhaseRows = remainingPhaseRows.map(r => (orderById.has(r.id) ? { ...r, order: orderById.get(r.id)! } : r));
// Удалили как раз ту строку, что была активной («На согласовании» и т.п.) —
// очередь надо продвинуть на следующую по order, иначе фаза так и останется
// без единой активной строки.
const activationUpdates = computeActivationUpdates(renumberedPhaseRows, row.phase);
const statusById = new Map(activationUpdates.map(u => [u.id, u.status]));
const writeUpdates = mergeRowUpdates(renumberUpdates, activationUpdates);
setMatrix({
...matrix,
rows: matrix.rows
.filter(r => r.id !== row.id)
.map(r => {
const order = orderById.get(r.id);
const status = statusById.get(r.id);
return order !== undefined || status !== undefined ? { ...r, ...(order !== undefined && { order }), ...(status !== undefined && { status }) } : r;
})
});
requestManage
.removeRow(matrix, row.id)
.then(() => {
if (writeUpdates.length === 0) return;
// Удаление уже прошло — откатывать нечего, даже если пересохранение
// номеров/статусов не удастся. Просто сообщаем об ошибке: строка
// гарантированно удалена, а нумерация/активация в SharePoint в худшем
// случае разойдётся с тем, что видно на экране (починится обычной
// перестановкой любого шага той же фазы).
return requestManage.reorderRows(matrix, writeUpdates).catch((e: Error) => setSaveError(reportError(e, 'confirmDelete: reorderRows')));
})
.catch((e: Error) => {
setMatrix(previous);
setSaveError(reportError(e, 'confirmDelete: removeRow'));
});
};
// Заявка полностью закрыта — маршрут только для просмотра целиком, независимо
// от статуса отдельных строк (см. isFullyClosedRequestStatus).
const readOnly = matrix ? isFullyClosedRequestStatus(matrix.request.status) : true;
// Заявка отклонена — дальше по маршруту дело не пойдёт ни в одной фазе, значит
// добавлять исполнителя нельзя нигде, даже в фазу, где ещё вообще нет строк
// (readOnly для этого не подходит: он рассчитан только на REQUEST_STATUS.closed
// и делает ВСЮ заявку неизменяемой, а отклонённую всё ещё можно редактировать —
// переназначать/удалять уже существующие шаги, просто новые уже некуда).
const requestRejected = matrix?.request.status === REQUEST_STATUS.rejected;
// Сортировка по order — ОБЯЗАТЕЛЬНА, а не косметика: ManageRequestsPhaseTable
// считает блоки и параллельность по ФИЗИЧЕСКОМУ порядку строк в массиве
// (markParallel сравнивает order с предыдущей строкой), а оптимистичные
// обновления (handleReorder/confirmDelete) патчут только значения order,
// оставляя порядок элементов массива прежним. Без пересортировки после
// первой же перестановки массив перестаёт быть отсортированным, и следующий
// клик по стрелке даёт пустой diffOrders — то есть кнопка молча перестаёт
// работать до перезагрузки матрицы с сервера.
//
// Сортировка стабильная (ES2019+), поэтому строки параллельного шага
// (одинаковый order) сохраняют относительный порядок.
const sortByOrder = (rows: ManagedApprovalRow[]): ManagedApprovalRow[] => [...rows].sort((a, b) => a.order - b.order);
const approvalRows = matrix ? sortByOrder(matrix.rows.filter(r => r.phase !== APPROVAL_PHASE.execution)) : [];
const executionRows = matrix ? sortByOrder(matrix.rows.filter(r => r.phase === APPROVAL_PHASE.execution)) : [];
const openReassign = (row: ManagedApprovalRow): void => setModal(row);
const closeModal = (): void => setModal(undefined);
const openSla = (row: ManagedApprovalRow): void => setSlaRow(row);
const closeSla = (): void => setSlaRow(undefined);
// SLA правится так же оптимистично, как переназначение: строка в матрице
// обновляется по ответу сервера, без перезагрузки всей матрицы.
const saveSla = (
// eslint-disable-next-line @rushstack/no-new-null -- null = «не задано», работает SLA типа заявки
slaHours: number | null,
// eslint-disable-next-line @rushstack/no-new-null -- см. выше
askOverdueReason: boolean | null
): Promise<void> => {
if (!matrix || !slaRow) return Promise.resolve();
setSaveError(undefined);
const rowId = slaRow.id;
return requestManage
.updateRowSla(matrix, rowId, slaHours, askOverdueReason)
.then(() => {
setMatrix(prev => (prev ? patchRow(prev, rowId, { slaHours, askOverdueReason }) : prev));
closeSla();
})
.catch((e: Error) => {
setSaveError(reportError(e, 'saveSla: updateRowSla'));
throw e;
});
};
// Кого нельзя выбрать при переназначении — все остальные согласующие/
// исполнители ТОЙ ЖЕ фазы (сама строка modal исключена, иначе нельзя было
// бы оставить текущего человека на месте). Один человек не может стоять в
// фазе дважды — та же гарантия, что и addableEmployees в
// ManageRequestsPhaseTable для добавления нового шага.
const reassignExcludedEmails = matrix && modal
? matrix.rows
.filter(r => r.phase === modal.phase && r.id !== modal.id)
.map(r => r.approverEmail.toLowerCase())
.filter(Boolean)
: [];
// Переназначение — тоже оптимистично: patch конкретной строки сразу по ответу
// сервера, без похода за всей матрицей заново. approverName берём из уже
// загруженного справочника сотрудников (тот же источник, что подставляет
// должность, — см. ManageRequestsApproverModal), чтобы не делать лишний
// запрос только за отображаемым именем.
const saveModal = (email: string, position: string): Promise<void> => {
if (!matrix || !modal) return Promise.resolve();
setSaveError(undefined);
const rowId = modal.id;
return requestManage
.reassignApprover(matrix, rowId, email, position)
.then(() => {
const found = findEmployeeByEmail(employees.items, email);
setMatrix(prev =>
prev ? patchRow(prev, rowId, { approverEmail: email, approverName: found?.fio || email, position }) : prev
);
closeModal();
})
.catch((e: Error) => {
setSaveError(reportError(e, 'saveModal: reassignApprover'));
throw e;
});
};
// Добавление нового шага — плашка-пикер под таблицей (ManageRequestsPhaseTable),
// без модалки (как в request-portal). Строка появляется в матрице СРАЗУ, с
// временным отрицательным Id («призрачная» — см. nextGhostId и isGhostRow в
// ManageRequestsPhaseTable, там ей запрещены переназначение/удаление/
// перестановка, пока она не подтверждена сервером). Настоящий Id из
// SharePoint долетает следом через тихую фоновую перезагрузку, которая
// подменяет призрачную строку настоящей. Если запрос упадёт — просто
// убираем призрак и показываем ошибку.
const handleAddApprover = (phase: string, email: string): void => {
if (!matrix) return;
setSaveError(undefined);
const phaseRows = phase === APPROVAL_PHASE.execution ? executionRows : approvalRows;
const found = findEmployeeByEmail(employees.items, email);
const position = found?.position || '';
const order = nextOrder(phaseRows);
const ghostId = nextGhostId.current--;
// Новая строка встаёт ПОСЛЕДНЕЙ по order (см. nextOrder) — значит, стать
// активной сразу она может, только если во всей фазе вообще нет ни одной
// ожидающей строки (например, первый исполнитель, добавленный в заявку, где
// до этого фазы исполнения не было вовсе): тогда «первый по order среди
// ожидающих» — она и есть. Если в фазе уже кто-то ждёт своей очереди, та
// строка всегда раньше по order — новая просто встаёт в конец «Ждёт очереди».
const hasPending = phaseRows.some(r => PENDING_STATUSES.indexOf(r.status) !== -1);
const activeStatus = phase === APPROVAL_PHASE.execution ? REQUEST_STATUS.onExecution : REQUEST_STATUS.onApproval;
const initialStatus = hasPending ? REQUEST_STATUS.queued : activeStatus;
const ghostRow: ManagedApprovalRow = {
id: ghostId,
ticketId: matrix.request.id,
approverId: null,
approverName: found?.fio || email,
approverEmail: email,
position,
status: initialStatus,
date: '',
comment: '',
order,
allowReassign: false,
phase,
// Шаг, добавленный вручную, маршрутом-шаблоном не порождён и своего SLA не
// имеет — работает SLA типа заявки, пока его не зададут отдельно.
slaHours: null,
askOverdueReason: null
};
const previous = matrix;
setMatrix({ ...matrix, rows: [...matrix.rows, ghostRow] });
requestManage
.addRow(matrix, matrix.request.id, phase, order, initialStatus, email, position, false)
.then(() => reloadMatrix(true))
.catch((e: Error) => {
setMatrix(previous);
setSaveError(reportError(e, 'handleAddApprover: addRow'));
});
};
// Перестановка/параллельность — оптимистично и БЕЗ блокировки: order из
// ManageRequestsPhaseTable применяется к матрице СРАЗУ (каждый клик по
// стрелке/значку виден мгновенно, в т.ч. при быстрых повторных кликах —
// предыдущий фоновый запрос ничего не блокирует), запрос на запись уходит
// фоном. При ошибке — не откатываем к «состоянию до клика»: если кликов было
// несколько подряд, такой откат затёр бы более поздние, ещё не сохранённые
// изменения. Вместо этого просто перечитываем матрицу с сервера — она сама
// покажет, что в итоге реально сохранилось.
const handleReorder = (updates: Array<{ id: number; order: number }>): void => {
if (!matrix || updates.length === 0) return;
setSaveError(undefined);
const orderById = new Map(updates.map(u => [u.id, u.order]));
const nextRows = matrix.rows.map(r => (orderById.has(r.id) ? { ...r, order: orderById.get(r.id)! } : r));
// order-обновления всегда относятся к ОДНОЙ фазе (перестановка идёт внутри
// одной таблицы — см. ManageRequestsPhaseTable), поэтому фазу для пересчёта
// активации достаточно взять у любой из задетых строк.
const changedPhase = nextRows.filter(r => orderById.has(r.id))[0].phase;
const activationUpdates = computeActivationUpdates(nextRows.filter(r => r.phase === changedPhase), changedPhase);
const statusById = new Map(activationUpdates.map(u => [u.id, u.status]));
setMatrix({
...matrix,
rows: nextRows.map(r => (statusById.has(r.id) ? { ...r, status: statusById.get(r.id)! } : r))
});
const writeUpdates = mergeRowUpdates(updates, activationUpdates);
requestManage.reorderRows(matrix, writeUpdates).catch((e: Error) => {
setSaveError(reportError(e, 'handleReorder: reorderRows'));
reloadMatrix(true);
});
};
// Отправка уведомления — по явному клику (в отличие от request-portal, где
// это происходит автоматически после решения), поэтому здесь показываем
// пользователю результат, а не глушим ошибку молча.
const handleNotify = (row: ManagedApprovalRow): void => {
if (!matrix || !requestType) return;
setNotifyingRowId(row.id);
requestManage
.notifyRow(requestType.id, matrix, row.id)
.then(() => setNotifyResult({ message: texts.manageRequests.notifySuccess, kind: 'ok' }))
.catch((e: Error) => setNotifyResult({ message: reportError(e, 'handleNotify: notifyRow'), kind: 'err' }))
.finally(() => setNotifyingRowId(undefined));
};
return (
<div>
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.manageRequests.breadcrumb }]} />
<PageHeader icon="clipboard" subtitle={texts.manageRequests.subtitle} title={texts.manageRequests.title} />
<Panel>
<FormGrid>
<Field
label={texts.manageRequests.requestTypeLabel}
required
hint={!requestTypes.isLoading && manageableRequestTypes.length === 0 ? texts.manageRequests.requestTypeEmpty : undefined}
>
<Select
options={manageableRequestTypes.map(rt => ({ value: String(rt.id), label: rt.name }))}
placeholder={texts.manageRequests.requestTypePlaceholder}
value={requestTypeId}
onChange={e => selectRequestType(e.target.value)}
/>
</Field>
<Field label={texts.manageRequests.requestLabel} required hint={!requestType ? texts.manageRequests.requestDisabledHint : undefined}>
<AsyncSearchableSelect<ManagedRequestSummary>
key={requestTypeId || 'none'}
value={selectedRequest ? String(selectedRequest.id) : ''}
selectedLabel={selectedRequest ? `#${selectedRequest.id} ${selectedRequest.title}` : undefined}
selectedSublabel={selectedRequest?.status}
disabled={!requestType}
placeholder={texts.manageRequests.requestPlaceholder}
searchPlaceholder={texts.manageRequests.requestSearchPlaceholder}
search={searchRequests}
getValue={r => String(r.id)}
getLabel={r => `#${r.id} ${r.title}`}
getSublabel={r => r.status}
onChange={(_, item) => selectRequest(item)}
/>
</Field>
</FormGrid>
</Panel>
{requestTypes.error && <Alert kind="err">{requestTypes.error}</Alert>}
{matrixError && <Alert kind="err">{texts.manageRequests.loadError}: {matrixError}</Alert>}
{matrixLoading && <Panel>{texts.common.loading}</Panel>}
{!matrixLoading && matrix && (
<Panel>
<div className={styles.requestHeader}>
<PrimaryCell>{matrix.request.title}</PrimaryCell>
<Badge variant={statusVariant(matrix.request.status)}>{matrix.request.status || texts.common.empty}</Badge>
</div>
{readOnly && <Alert kind="warn">{texts.manageRequests.fullyClosedNotice}</Alert>}
{!matrix.approvalListTitle && <Alert kind="warn">{texts.manageRequests.noApprovalList}</Alert>}
{matrix.approvalListTitle && (
<div className={styles.matrix}>
<ManageRequestsPhaseTable
title={texts.manageRequests.approvalPhaseTitle}
rows={approvalRows}
readOnly={readOnly}
requestRejected={requestRejected}
statusVariant={statusVariant}
onReassign={openReassign}
onEditSla={openSla}
onRemove={askDelete}
onNotify={handleNotify}
notifyingRowId={notifyingRowId}
employees={employees.items}
employeesLoading={employees.isLoading}
addPlaceholder={texts.manageRequests.addApproverPlaceholder}
onAddApprover={email => handleAddApprover(APPROVAL_PHASE.approval, email)}
onReorder={handleReorder}
/>
<ManageRequestsPhaseTable
title={texts.manageRequests.executionPhaseTitle}
rows={executionRows}
readOnly={readOnly}
requestRejected={requestRejected}
statusVariant={statusVariant}
onReassign={openReassign}
onEditSla={openSla}
onRemove={askDelete}
onNotify={handleNotify}
notifyingRowId={notifyingRowId}
employees={employees.items}
employeesLoading={employees.isLoading}
addPlaceholder={texts.manageRequests.addExecutorPlaceholder}
onAddApprover={email => handleAddApprover(APPROVAL_PHASE.execution, email)}
onReorder={handleReorder}
/>
</div>
)}
</Panel>
)}
{modal && (
<ManageRequestsApproverModal
title={texts.manageRequests.reassignTitle}
initialEmail={modal.approverEmail}
employees={employees.items}
employeesLoading={employees.isLoading}
excludedEmails={reassignExcludedEmails}
onSave={saveModal}
onClose={closeModal}
/>
)}
{slaRow && (
<ManageRequestsSlaModal
orderNo={slaRow.order}
slaHours={slaRow.slaHours}
askOverdueReason={slaRow.askOverdueReason}
onSave={saveSla}
onClose={closeSla}
/>
)}
{deleteRow && (
<ConfirmDialog
title={texts.manageRequests.deleteTitle}
text={texts.manageRequests.deleteText(deleteRow.position)}
confirmLabel={texts.common.remove}
onCancel={cancelDelete}
onConfirm={confirmDelete}
/>
)}
{saveError && (
<Toast message={`${texts.manageRequests.saveError}: ${saveError}`} onClose={() => setSaveError(undefined)} />
)}
{notifyResult && (
<Toast
message={notifyResult.kind === 'ok' ? notifyResult.message : `${texts.manageRequests.notifyError}: ${notifyResult.message}`}
kind={notifyResult.kind}
onClose={() => setNotifyResult(undefined)}
/>
)}
</div>
);
};