660 lines
38 KiB
TypeScript
660 lines
38 KiB
TypeScript
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>
|
||
);
|
||
};
|