обработка ошибок

This commit is contained in:
gorbenko.pavel
2026-08-07 14:27:58 +05:00
parent 03391d7669
commit 6dcea0fe02
7 changed files with 173 additions and 12 deletions
@@ -64,6 +64,14 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
onAddApprover,
onReorder
}) => {
// Подсветка строки при наведении — на уровне БЛОКА, а не через CSS
// tbody tr:hover (см. .rowHover в ManageRequestsScreen.module.scss): ячейки
// с rowSpan (номер шага, стрелки перестановки) физически принадлежат <tr>
// только первой строки блока, поэтому обычный :hover при наведении на
// вторую/третью строку параллельного блока их не подсвечивал — получался
// "вырез" в жёлтом фоне ровно на границе объединённой ячейки.
const [hoveredBlock, setHoveredBlock] = React.useState<number | undefined>(undefined);
const withParallel = React.useMemo(() => markParallel(rows) as Row[], [rows]);
const blocks = React.useMemo(() => blockRanges(withParallel), [withParallel]);
const lockedBlock = React.useMemo(
@@ -131,7 +139,7 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
{rows.length === 0 && <div className={tableStyles.wrap}><div className={tableStyles.empty}>{texts.manageRequests.emptyPhase}</div></div>}
{rows.length > 0 && (
<div className={tableStyles.wrap}>
<div className={`${tableStyles.wrap} ${styles.blockAwareWrap}`}>
<div className={tableStyles.scroll}>
<table className={tableStyles.table}>
<thead>
@@ -161,7 +169,12 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
const isGhost = r.id < 0;
return (
<tr key={r.id}>
<tr
key={r.id}
className={blockIndex === hoveredBlock ? styles.rowHover : undefined}
onMouseEnter={() => setHoveredBlock(blockIndex)}
onMouseLeave={() => setHoveredBlock(undefined)}
>
{isBlockStart && (
<td className={styles.stepCol} rowSpan={block.end - block.start + 1}>
{r.parallelWithPrev || block.end > block.start ? (
@@ -49,6 +49,23 @@
svg { transform: scaleY(-1); }
}
// Подсветка строки при наведении здесь — своя, НЕ штатный DataTable.module.scss
// tbody tr:hover: та подсветка вешается через :hover на конкретный <tr>, а
// ячейки с rowSpan (номер шага, стрелки перестановки) физически принадлежат
// <tr> только первой строки блока — при наведении на вторую/третью строку
// параллельного блока эти ячейки не подсвечивались, получался видимый "вырез"
// в жёлтом фоне ровно на границе объединённой ячейки. Поэтому подсветка здесь
// — на уровне блока через React-состояние (см. hoveredBlock в
// ManageRequestsPhaseTable), а не через CSS :hover, а штатный tr:hover для
// этой конкретной таблицы отключаем, чтобы они не накладывались друг на друга.
.blockAwareWrap :global(tbody tr:hover) {
background: transparent;
}
.rowHover td {
background: $gold-50;
}
// IconButton по умолчанию (Button.module.scss/.iconButton) прозрачная — рамка
// появляется только по hover. В этой таблице кнопок много и часть из них не
// самоочевидна по смыслу (параллельность, перестановка шага), поэтому здесь
@@ -18,6 +18,7 @@ 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 {
@@ -32,7 +33,8 @@ import {
PageHeader,
Panel,
PrimaryCell,
Select
Select,
Toast
} from '../ui';
export interface IManageRequestsScreenProps {
@@ -240,7 +242,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
return same ? prev : { ...prev, selectedRequest: m.request };
});
})
.catch((e: Error) => setMatrixError(e.message))
.catch((e: Error) => setMatrixError(reportError(e, 'reloadMatrix: getMatrix')))
.finally(() => {
if (!silent) setMatrixLoading(false);
});
@@ -307,11 +309,11 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
// гарантированно удалена, а нумерация/активация в SharePoint в худшем
// случае разойдётся с тем, что видно на экране (починится обычной
// перестановкой любого шага той же фазы).
return requestManage.reorderRows(matrix, writeUpdates).catch((e: Error) => setSaveError(e.message));
return requestManage.reorderRows(matrix, writeUpdates).catch((e: Error) => setSaveError(reportError(e, 'confirmDelete: reorderRows')));
})
.catch((e: Error) => {
setMatrix(previous);
setSaveError(e.message);
setSaveError(reportError(e, 'confirmDelete: removeRow'));
});
};
@@ -319,8 +321,21 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
// от статуса отдельных строк (см. 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) : [];
// Сортировка по 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);
@@ -344,7 +359,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
closeModal();
})
.catch((e: Error) => {
setSaveError(e.message);
setSaveError(reportError(e, 'saveModal: reassignApprover'));
throw e;
});
};
@@ -399,7 +414,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
.then(() => reloadMatrix(true))
.catch((e: Error) => {
setMatrix(previous);
setSaveError(e.message);
setSaveError(reportError(e, 'handleAddApprover: addRow'));
});
};
@@ -432,7 +447,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
const writeUpdates = mergeRowUpdates(updates, activationUpdates);
requestManage.reorderRows(matrix, writeUpdates).catch((e: Error) => {
setSaveError(e.message);
setSaveError(reportError(e, 'handleReorder: reorderRows'));
reloadMatrix(true);
});
};
@@ -477,7 +492,6 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
{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>}
@@ -544,6 +558,10 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
onConfirm={confirmDelete}
/>
)}
{saveError && (
<Toast message={`${texts.manageRequests.saveError}: ${saveError}`} onClose={() => setSaveError(undefined)} />
)}
</div>
);
};
@@ -0,0 +1,47 @@
@import '../../../../styles/design-tokens';
// position: fixed относительно viewport — веб-часть встроена в страницу
// SharePoint, а не в отдельное модальное окно, поэтому уголок экрана, а не
// уголок какого-то контейнера.
.toast {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 1000;
display: flex;
align-items: flex-start;
gap: 10px;
max-width: 380px;
padding: 14px 16px;
border-radius: $r-md;
background: $red-50;
color: $red-500;
border: 1px solid #FECACA;
box-shadow: $sh-3;
font-size: 12.5px;
line-height: 1.5;
animation: toastIn .15s ease-out;
}
.message {
flex: 1;
}
.close {
flex-shrink: 0;
border: none;
background: none;
cursor: pointer;
color: inherit;
opacity: .6;
font-size: 16px;
line-height: 1;
padding: 0;
&:hover { opacity: 1; }
}
@keyframes toastIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@@ -0,0 +1,33 @@
import * as React from 'react';
import styles from './Toast.module.scss';
import { Icon } from './Icon';
export interface IToastProps {
message: string;
onClose: () => void;
}
const AUTO_HIDE_MS = 6000;
/**
* Всплывающее сообщение об ошибке в углу экрана — для действий, которые не
* ломают уже отрисованный экран (переназначение/перестановка/добавление шага):
* пользователь и так видит, что изменение не применилось (см. reloadMatrix
* после ошибки в ManageRequestsScreen), поэтому постоянный Alert-блок сверху
* страницы был бы избыточен. Автоматически прячется через AUTO_HIDE_MS, либо
* закрывается вручную.
*/
export const Toast: React.FC<IToastProps> = ({ message, onClose }) => {
React.useEffect(() => {
const timer = window.setTimeout(onClose, AUTO_HIDE_MS);
return () => window.clearTimeout(timer);
}, [message, onClose]);
return (
<div className={styles.toast} role="alert">
<Icon name="warning" size="sm" />
<span className={styles.message}>{message}</span>
<button type="button" className={styles.close} onClick={onClose} aria-label="Закрыть">×</button>
</div>
);
};
@@ -3,6 +3,7 @@ export * from './Button';
export * from './IconButton';
export * from './Badge';
export * from './Alert';
export * from './Toast';
export * from './Panel';
export * from './Breadcrumbs';
export * from './PageHeader';
@@ -0,0 +1,32 @@
// PnPjs (getById(...).update(...) и т.п.) при ошибке SharePoint кладёт в
// Error.message не только код HTTP, но и ВЕСЬ сырой текст ответа сервера —
// это JSON вида {"odata.error":{"code":"...","message":{"lang":"ru-RU","value":"..."}}}.
// Если просто вывести e.message пользователю, он увидит фигурные скобки,
// "odata.error" и escape-последовательности \uXXXX прямо как текст (это НЕ
// баг кодировки — JSON.parse их корректно раскрывает в кириллицу, но e.message
// это просто СТРОКА, а не распарсенный объект, вот они и остаются как есть).
interface ISharePointErrorEnvelope {
'odata.error'?: { message?: { value?: string } };
}
/** Читаемый текст ошибки — вытаскивает odata.error.message.value, если он есть. */
export function describeError(e: unknown): string {
const message = e instanceof Error ? e.message : String(e);
const jsonStart = message.indexOf('{');
if (jsonStart === -1) return message;
try {
const parsed = JSON.parse(message.slice(jsonStart)) as ISharePointErrorEnvelope;
return parsed['odata.error']?.message?.value || message;
} catch {
// Не JSON (или обрезан) — отдаём как есть, лучше сырой текст, чем ничего.
return message;
}
}
/** Полная ошибка — в консоль (для диагностики), пользователю возвращается только читаемый текст. */
export function reportError(e: unknown, context: string): string {
// eslint-disable-next-line no-console
console.error(`[ApprovalAdmin] ${context}`, e);
return describeError(e);
}