diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx index 6dc875e..637ed16 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx @@ -64,6 +64,14 @@ export const ManageRequestsPhaseTable: React.FC onAddApprover, onReorder }) => { + // Подсветка строки при наведении — на уровне БЛОКА, а не через CSS + // tbody tr:hover (см. .rowHover в ManageRequestsScreen.module.scss): ячейки + // с rowSpan (номер шага, стрелки перестановки) физически принадлежат + // только первой строки блока, поэтому обычный :hover при наведении на + // вторую/третью строку параллельного блока их не подсвечивал — получался + // "вырез" в жёлтом фоне ровно на границе объединённой ячейки. + const [hoveredBlock, setHoveredBlock] = React.useState(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 {rows.length === 0 &&
{texts.manageRequests.emptyPhase}
} {rows.length > 0 && ( -
+
@@ -161,7 +169,12 @@ export const ManageRequestsPhaseTable: React.FC const isGhost = r.id < 0; return ( - + setHoveredBlock(blockIndex)} + onMouseLeave={() => setHoveredBlock(undefined)} + > {isBlockStart && ( , а +// ячейки с rowSpan (номер шага, стрелки перестановки) физически принадлежат +// только первой строки блока — при наведении на вторую/третью строку +// параллельного блока эти ячейки не подсвечивались, получался видимый "вырез" +// в жёлтом фоне ровно на границе объединённой ячейки. Поэтому подсветка здесь +// — на уровне блока через 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. В этой таблице кнопок много и часть из них не // самоочевидна по смыслу (параллельность, перестановка шага), поэтому здесь diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx index c44b034..784bba0 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ api closeModal(); }) .catch((e: Error) => { - setSaveError(e.message); + setSaveError(reportError(e, 'saveModal: reassignApprover')); throw e; }); }; @@ -399,7 +414,7 @@ export const ManageRequestsScreen: React.FC = ({ 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 = ({ 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 = ({ api {requestTypes.error && {requestTypes.error}} {matrixError && {texts.manageRequests.loadError}: {matrixError}} - {saveError && {texts.manageRequests.saveError}: {saveError}} {matrixLoading && {texts.common.loading}} @@ -544,6 +558,10 @@ export const ManageRequestsScreen: React.FC = ({ api onConfirm={confirmDelete} /> )} + + {saveError && ( + setSaveError(undefined)} /> + )} ); }; diff --git a/frontend/src/webparts/approvalAdmin/components/ui/Toast.module.scss b/frontend/src/webparts/approvalAdmin/components/ui/Toast.module.scss new file mode 100644 index 0000000..3a600e3 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/ui/Toast.module.scss @@ -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); } +} diff --git a/frontend/src/webparts/approvalAdmin/components/ui/Toast.tsx b/frontend/src/webparts/approvalAdmin/components/ui/Toast.tsx new file mode 100644 index 0000000..3d9db79 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/ui/Toast.tsx @@ -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 = ({ message, onClose }) => { + React.useEffect(() => { + const timer = window.setTimeout(onClose, AUTO_HIDE_MS); + return () => window.clearTimeout(timer); + }, [message, onClose]); + + return ( +
+ + {message} + +
+ ); +}; diff --git a/frontend/src/webparts/approvalAdmin/components/ui/index.ts b/frontend/src/webparts/approvalAdmin/components/ui/index.ts index fb17e8f..34e461a 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/index.ts +++ b/frontend/src/webparts/approvalAdmin/components/ui/index.ts @@ -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'; diff --git a/frontend/src/webparts/approvalAdmin/utils/errors.ts b/frontend/src/webparts/approvalAdmin/utils/errors.ts new file mode 100644 index 0000000..d97ae4b --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/utils/errors.ts @@ -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); +}
{r.parallelWithPrev || block.end > block.start ? ( diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss index 5883a6a..dc89086 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss @@ -49,6 +49,23 @@ svg { transform: scaleY(-1); } } +// Подсветка строки при наведении здесь — своя, НЕ штатный DataTable.module.scss +// tbody tr:hover: та подсветка вешается через :hover на конкретный