обработка ошибок
This commit is contained in:
+15
-2
@@ -64,6 +64,14 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
|
|||||||
onAddApprover,
|
onAddApprover,
|
||||||
onReorder
|
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 withParallel = React.useMemo(() => markParallel(rows) as Row[], [rows]);
|
||||||
const blocks = React.useMemo(() => blockRanges(withParallel), [withParallel]);
|
const blocks = React.useMemo(() => blockRanges(withParallel), [withParallel]);
|
||||||
const lockedBlock = React.useMemo(
|
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.empty}>{texts.manageRequests.emptyPhase}</div></div>}
|
||||||
|
|
||||||
{rows.length > 0 && (
|
{rows.length > 0 && (
|
||||||
<div className={tableStyles.wrap}>
|
<div className={`${tableStyles.wrap} ${styles.blockAwareWrap}`}>
|
||||||
<div className={tableStyles.scroll}>
|
<div className={tableStyles.scroll}>
|
||||||
<table className={tableStyles.table}>
|
<table className={tableStyles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -161,7 +169,12 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
|
|||||||
const isGhost = r.id < 0;
|
const isGhost = r.id < 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={r.id}>
|
<tr
|
||||||
|
key={r.id}
|
||||||
|
className={blockIndex === hoveredBlock ? styles.rowHover : undefined}
|
||||||
|
onMouseEnter={() => setHoveredBlock(blockIndex)}
|
||||||
|
onMouseLeave={() => setHoveredBlock(undefined)}
|
||||||
|
>
|
||||||
{isBlockStart && (
|
{isBlockStart && (
|
||||||
<td className={styles.stepCol} rowSpan={block.end - block.start + 1}>
|
<td className={styles.stepCol} rowSpan={block.end - block.start + 1}>
|
||||||
{r.parallelWithPrev || block.end > block.start ? (
|
{r.parallelWithPrev || block.end > block.start ? (
|
||||||
|
|||||||
+17
@@ -49,6 +49,23 @@
|
|||||||
svg { transform: scaleY(-1); }
|
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) прозрачная — рамка
|
// IconButton по умолчанию (Button.module.scss/.iconButton) прозрачная — рамка
|
||||||
// появляется только по hover. В этой таблице кнопок много и часть из них не
|
// появляется только по hover. В этой таблице кнопок много и часть из них не
|
||||||
// самоочевидна по смыслу (параллельность, перестановка шага), поэтому здесь
|
// самоочевидна по смыслу (параллельность, перестановка шага), поэтому здесь
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { useRequestTypes } from '../../hooks/useRequestTypes';
|
|||||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||||
import { findEmployeeByEmail } from '../../utils/employee';
|
import { findEmployeeByEmail } from '../../utils/employee';
|
||||||
|
import { reportError } from '../../utils/errors';
|
||||||
import { markParallel, withOrdersFrom } from '../../utils/orderBlocks';
|
import { markParallel, withOrdersFrom } from '../../utils/orderBlocks';
|
||||||
import { IManageRequestRouteParams, replaceRoute } from '../../routing';
|
import { IManageRequestRouteParams, replaceRoute } from '../../routing';
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +33,8 @@ import {
|
|||||||
PageHeader,
|
PageHeader,
|
||||||
Panel,
|
Panel,
|
||||||
PrimaryCell,
|
PrimaryCell,
|
||||||
Select
|
Select,
|
||||||
|
Toast
|
||||||
} from '../ui';
|
} from '../ui';
|
||||||
|
|
||||||
export interface IManageRequestsScreenProps {
|
export interface IManageRequestsScreenProps {
|
||||||
@@ -240,7 +242,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
return same ? prev : { ...prev, selectedRequest: m.request };
|
return same ? prev : { ...prev, selectedRequest: m.request };
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((e: Error) => setMatrixError(e.message))
|
.catch((e: Error) => setMatrixError(reportError(e, 'reloadMatrix: getMatrix')))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!silent) setMatrixLoading(false);
|
if (!silent) setMatrixLoading(false);
|
||||||
});
|
});
|
||||||
@@ -307,11 +309,11 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
// гарантированно удалена, а нумерация/активация в SharePoint в худшем
|
// гарантированно удалена, а нумерация/активация в 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) => {
|
.catch((e: Error) => {
|
||||||
setMatrix(previous);
|
setMatrix(previous);
|
||||||
setSaveError(e.message);
|
setSaveError(reportError(e, 'confirmDelete: removeRow'));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -319,8 +321,21 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
// от статуса отдельных строк (см. isFullyClosedRequestStatus).
|
// от статуса отдельных строк (см. isFullyClosedRequestStatus).
|
||||||
const readOnly = matrix ? isFullyClosedRequestStatus(matrix.request.status) : true;
|
const readOnly = matrix ? isFullyClosedRequestStatus(matrix.request.status) : true;
|
||||||
|
|
||||||
const approvalRows = matrix ? matrix.rows.filter(r => r.phase !== APPROVAL_PHASE.execution) : [];
|
// Сортировка по order — ОБЯЗАТЕЛЬНА, а не косметика: ManageRequestsPhaseTable
|
||||||
const executionRows = matrix ? matrix.rows.filter(r => r.phase === APPROVAL_PHASE.execution) : [];
|
// считает блоки и параллельность по ФИЗИЧЕСКОМУ порядку строк в массиве
|
||||||
|
// (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 openReassign = (row: ManagedApprovalRow): void => setModal(row);
|
||||||
const closeModal = (): void => setModal(undefined);
|
const closeModal = (): void => setModal(undefined);
|
||||||
@@ -344,7 +359,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
closeModal();
|
closeModal();
|
||||||
})
|
})
|
||||||
.catch((e: Error) => {
|
.catch((e: Error) => {
|
||||||
setSaveError(e.message);
|
setSaveError(reportError(e, 'saveModal: reassignApprover'));
|
||||||
throw e;
|
throw e;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -399,7 +414,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
.then(() => reloadMatrix(true))
|
.then(() => reloadMatrix(true))
|
||||||
.catch((e: Error) => {
|
.catch((e: Error) => {
|
||||||
setMatrix(previous);
|
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);
|
const writeUpdates = mergeRowUpdates(updates, activationUpdates);
|
||||||
requestManage.reorderRows(matrix, writeUpdates).catch((e: Error) => {
|
requestManage.reorderRows(matrix, writeUpdates).catch((e: Error) => {
|
||||||
setSaveError(e.message);
|
setSaveError(reportError(e, 'handleReorder: reorderRows'));
|
||||||
reloadMatrix(true);
|
reloadMatrix(true);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -477,7 +492,6 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
|
|
||||||
{requestTypes.error && <Alert kind="err">{requestTypes.error}</Alert>}
|
{requestTypes.error && <Alert kind="err">{requestTypes.error}</Alert>}
|
||||||
{matrixError && <Alert kind="err">{texts.manageRequests.loadError}: {matrixError}</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 && <Panel>{texts.common.loading}</Panel>}
|
||||||
|
|
||||||
@@ -544,6 +558,10 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
onConfirm={confirmDelete}
|
onConfirm={confirmDelete}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{saveError && (
|
||||||
|
<Toast message={`${texts.manageRequests.saveError}: ${saveError}`} onClose={() => setSaveError(undefined)} />
|
||||||
|
)}
|
||||||
</div>
|
</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 './IconButton';
|
||||||
export * from './Badge';
|
export * from './Badge';
|
||||||
export * from './Alert';
|
export * from './Alert';
|
||||||
|
export * from './Toast';
|
||||||
export * from './Panel';
|
export * from './Panel';
|
||||||
export * from './Breadcrumbs';
|
export * from './Breadcrumbs';
|
||||||
export * from './PageHeader';
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user