куча правок
This commit is contained in:
@@ -0,0 +1,442 @@
|
||||
import * as React from 'react';
|
||||
import styles from './ManageRequestsScreen.module.scss';
|
||||
import { ManageRequestsApproverModal } from './ManageRequestsApproverModal';
|
||||
import { ManageRequestsPhaseTable } from './ManageRequestsPhaseTable';
|
||||
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 { markParallel, withOrdersFrom } from '../../utils/orderBlocks';
|
||||
import {
|
||||
Alert,
|
||||
AsyncSearchableSelect,
|
||||
Badge,
|
||||
BadgeVariant,
|
||||
Breadcrumbs,
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
FormGrid,
|
||||
PageHeader,
|
||||
Panel,
|
||||
PrimaryCell,
|
||||
Select
|
||||
} from '../ui';
|
||||
|
||||
export interface IManageRequestsScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||
dictionary?: PortalDictionaryClient;
|
||||
requestManage: RequestManageService;
|
||||
}
|
||||
|
||||
// Цвет плашки статуса — те же группы смысла, что и в 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 }));
|
||||
}
|
||||
|
||||
// Что выбрано в комбобоксах «Тип заявки»/«Заявка» — переживает перезагрузку
|
||||
// страницы (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 }) => {
|
||||
const requestTypes = useRequestTypes(api);
|
||||
const employees = usePortalEmployees(dictionary);
|
||||
|
||||
const [selection, setSelection] = useLocalStorageState<IPersistedSelection>(SELECTION_STORAGE_KEY, EMPTY_SELECTION);
|
||||
const { requestTypeId, selectedRequest } = selection;
|
||||
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 [deleteRow, setDeleteRow] = React.useState<ManagedApprovalRow | undefined>();
|
||||
const [saveError, setSaveError] = React.useState<string | undefined>();
|
||||
// Отрицательные Id — «призрачные» строки: добавлены на клиенте, ответ сервера
|
||||
// (и настоящий Id из SharePoint) ещё не долетел. Реальные Id списка всегда
|
||||
// положительные, поэтому пересечься эти диапазоны не могут.
|
||||
const nextGhostId = React.useRef(-1);
|
||||
// Идёт фоновая запись (перестановка/переключение параллельности) — блокирует
|
||||
// ТОЛЬКО повторную перестановку, чтобы не отправить второй reorder поверх ещё
|
||||
// не завершённого (порядок мог бы разъехаться при гонке двух запросов).
|
||||
// Переназначение/удаление/добавление такой блокировки не требуют: они не
|
||||
// трогают order соседних строк, поэтому гонок между собой не создают.
|
||||
const [reordering, setReordering] = React.useState(false);
|
||||
|
||||
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(setMatrix)
|
||||
.catch((e: Error) => setMatrixError(e.message))
|
||||
.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]));
|
||||
|
||||
setMatrix({
|
||||
...matrix,
|
||||
rows: matrix.rows
|
||||
.filter(r => r.id !== row.id)
|
||||
.map(r => (orderById.has(r.id) ? { ...r, order: orderById.get(r.id)! } : r))
|
||||
});
|
||||
|
||||
requestManage
|
||||
.removeRow(matrix, row.id)
|
||||
.then(() => {
|
||||
if (renumberUpdates.length === 0) return;
|
||||
// Удаление уже прошло — откатывать нечего, даже если это пересохранение
|
||||
// номеров не удастся. Просто сообщаем об ошибке: строка гарантированно
|
||||
// удалена, а нумерация в SharePoint в худшем случае останется с дыркой
|
||||
// (что чинится обычной перестановкой любого шага той же фазы).
|
||||
return requestManage.reorderRows(matrix, renumberUpdates).catch((e: Error) => setSaveError(e.message));
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
setMatrix(previous);
|
||||
setSaveError(e.message);
|
||||
});
|
||||
};
|
||||
|
||||
// Заявка полностью закрыта — маршрут только для просмотра целиком, независимо
|
||||
// от статуса отдельных строк (см. isFullyClosedRequestStatus).
|
||||
const readOnly = matrix ? isFullyClosedRequestStatus(matrix.request.status) : true;
|
||||
|
||||
const approvalRows = matrix ? matrix.rows.filter(r => r.phase !== APPROVAL_PHASE.execution) : [];
|
||||
const executionRows = matrix ? matrix.rows.filter(r => r.phase === APPROVAL_PHASE.execution) : [];
|
||||
|
||||
const openReassign = (row: ManagedApprovalRow): void => setModal(row);
|
||||
const closeModal = (): void => setModal(undefined);
|
||||
|
||||
// Переназначение — тоже оптимистично: 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(e.message);
|
||||
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--;
|
||||
|
||||
// Новая строка всегда «Ждёт очереди» — request-portal переводит её в «На
|
||||
// согласовании»/«На исполнении» через activateNextQueued при следующем
|
||||
// решении согласующего или повторной отправке формы (см.
|
||||
// request-portal/shared/approval/approvalList.service.ts). Если это ПЕРВЫЙ
|
||||
// шаг фазы (например, добавили исполнителя в заявку, где фазы исполнения
|
||||
// раньше не было вовсе) — activateNextQueued не сработает сам по себе, пока
|
||||
// кто-то не примет решение по заявке; шаг до этого момента просто ждёт.
|
||||
const ghostRow: ManagedApprovalRow = {
|
||||
id: ghostId,
|
||||
ticketId: matrix.request.id,
|
||||
approverId: null,
|
||||
approverName: found?.fio || email,
|
||||
approverEmail: email,
|
||||
position,
|
||||
status: REQUEST_STATUS.queued,
|
||||
date: '',
|
||||
comment: '',
|
||||
order,
|
||||
allowReassign: false,
|
||||
phase
|
||||
};
|
||||
|
||||
const previous = matrix;
|
||||
setMatrix({ ...matrix, rows: [...matrix.rows, ghostRow] });
|
||||
|
||||
requestManage
|
||||
.addRow(matrix, matrix.request.id, phase, order, REQUEST_STATUS.queued, email, position, false)
|
||||
.then(() => reloadMatrix(true))
|
||||
.catch((e: Error) => {
|
||||
setMatrix(previous);
|
||||
setSaveError(e.message);
|
||||
});
|
||||
};
|
||||
|
||||
// Перестановка/параллельность — оптимистично: order из ManageRequestsPhaseTable
|
||||
// применяется к матрице СРАЗУ (клик по стрелке/значку виден мгновенно), запрос
|
||||
// на запись уходит фоном. Если он упадёт — откатываем к состоянию до клика и
|
||||
// показываем ошибку; если пройдёт — тихо перечитываем матрицу (не меняя
|
||||
// видимого состояния, если её никто больше не менял, но подхватит, если менял).
|
||||
const handleReorder = (updates: Array<{ id: number; order: number }>): void => {
|
||||
if (!matrix || reordering) return;
|
||||
setSaveError(undefined);
|
||||
|
||||
const previous = matrix;
|
||||
const orderById = new Map(updates.map(u => [u.id, u.order]));
|
||||
setMatrix({ ...matrix, rows: matrix.rows.map(r => (orderById.has(r.id) ? { ...r, order: orderById.get(r.id)! } : r)) });
|
||||
|
||||
setReordering(true);
|
||||
requestManage
|
||||
.reorderRows(matrix, updates)
|
||||
.then(() => reloadMatrix(true))
|
||||
.catch((e: Error) => {
|
||||
setMatrix(previous);
|
||||
setSaveError(e.message);
|
||||
})
|
||||
.finally(() => setReordering(false));
|
||||
};
|
||||
|
||||
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>}
|
||||
{saveError && <Alert kind="err">{texts.manageRequests.saveError}: {saveError}</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}
|
||||
busy={reordering}
|
||||
statusVariant={statusVariant}
|
||||
onReassign={openReassign}
|
||||
onRemove={askDelete}
|
||||
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}
|
||||
busy={reordering}
|
||||
statusVariant={statusVariant}
|
||||
onReassign={openReassign}
|
||||
onRemove={askDelete}
|
||||
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}
|
||||
onSave={saveModal}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteRow && (
|
||||
<ConfirmDialog
|
||||
title={texts.manageRequests.deleteTitle}
|
||||
text={texts.manageRequests.deleteText(deleteRow.position)}
|
||||
confirmLabel={texts.common.remove}
|
||||
onCancel={cancelDelete}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user