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): 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(); 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 = ({ api, dictionary, requestManage, route }) => { const requestTypes = useRequestTypes(api); const employees = usePortalEmployees(dictionary); const [selection, setSelection] = useLocalStorageState(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(); const [matrixLoading, setMatrixLoading] = React.useState(false); const [matrixError, setMatrixError] = React.useState(); const [modal, setModal] = React.useState(); const [slaRow, setSlaRow] = React.useState(); const [deleteRow, setDeleteRow] = React.useState(); const [saveError, setSaveError] = React.useState(); const [notifyResult, setNotifyResult] = React.useState<{ message: string; kind: 'ok' | 'err' } | undefined>(); const [notifyingRowId, setNotifyingRowId] = React.useState(); // Отрицательные 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 => { 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 => { 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 (