import * as React from 'react'; import tableStyles from '../ui/DataTable.module.scss'; import styles from './ManageRequestsScreen.module.scss'; import { Employee, ManagedApprovalRow, isEditableRowStatus } from '../../models'; import { texts } from '../../texts'; import { toEmployeeOption } from '../../utils/employee'; import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../utils/orderBlocks'; import { ActionsCell, Badge, BadgeVariant, CellNote, IconButton, PrimaryCell, SearchableSelect } from '../ui'; export interface IManageRequestsPhaseTableProps { title: string; /** Строки ОДНОЙ фазы, уже отсортированные по order (см. ManageRequestsScreen). */ rows: ManagedApprovalRow[]; /** Заявка целиком только для просмотра (закрыта) — контролов нет вообще. */ readOnly: boolean; statusVariant: (status: string) => BadgeVariant; onReassign: (row: ManagedApprovalRow) => void; onRemove: (row: ManagedApprovalRow) => void; employees: Employee[]; employeesLoading: boolean; /** Плейсхолдер плашки-пикера под таблицей — разный для согласования/исполнения. */ addPlaceholder: string; /** Должность подставляется из справочника (Employee.position) — здесь не спрашивается. */ onAddApprover: (email: string) => void; /** updates — только строки, у которых order реально изменился. */ onReorder: (updates: Array<{ id: number; order: number }>) => void; } type Row = ManagedApprovalRow & { parallelWithPrev: boolean }; function diffOrders(original: ManagedApprovalRow[], renumbered: Row[]): Array<{ id: number; order: number }> { const originalOrderById = new Map(original.map(r => [r.id, r.order])); return renumbered .filter(r => originalOrderById.get(r.id) !== r.order) .map(r => ({ id: r.id, order: r.order })); } /** * Таблица одной фазы маршрута с поддержкой перестановки шагов и параллельности — * повторяет request-portal/shared/approval/ui/ApprovalMatrix.tsx (та же идея: * колонка «Шаг» объединена по rowSpan на блок, кнопки ↑/↓ двигают блок целиком), * но с одним отличием: часть строк тут может быть УЖЕ РЕШЕНА (approved/executed/ * rejected/returned) — такие блоки «заблокированы» и никогда не двигаются и не * участвуют в переключении параллельности. Блокировка распространяется на все * блоки НЕ ПОЗЖЕ последнего заблокированного — по построению маршрута (шаги * решаются по возрастанию order) заблокированные блоки всегда образуют * непрерывный префикс, поэтому «после последнего заблокированного» и есть та * самая граница, ниже которой перестановка запрещена (см. utils/orderBlocks.ts). * * Переназначение/удаление СТРОКИ (не блока) разрешено независимо от соседей — * если у одного из двух параллельных согласующих решение уже принято, а у * другого нет, второго всё ещё можно переназначить. */ export const ManageRequestsPhaseTable: React.FC = ({ title, rows, readOnly, statusVariant, onReassign, onRemove, employees, employeesLoading, addPlaceholder, 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( () => blocks.map(b => withParallel.slice(b.start, b.end + 1).some(r => !isEditableRowStatus(r.status))), [blocks, withParallel] ); // Индекс последнего заблокированного блока — все блоки ПОСЛЕ него движимы, // все блоки НА НЁМ И РАНЬШЕ — нет (см. комментарий к компоненту). Считаем // именно НЕПРЕРЫВНЫЙ префикс заблокированных блоков с начала, а не индекс // последнего true по всему списку: реальные данные не всегда гарантируют // строгую непрерывность (например, более ранняя ручная правка тем же // инструментом могла оставить решённый шаг ПОСЛЕ ещё не решённого) — а // reduce по всему массиву в этом случае блокирует стрелки вообще у всех // блоков до самого дальнего заблокированного, даже у тех, что реально // редактируемы. let lastLockedBlockIndex = -1; for (let i = 0; i < lockedBlock.length; i++) { if (!lockedBlock[i]) break; lastLockedBlockIndex = i; } const firstMovableRowIndex = blocks[lastLockedBlockIndex + 1]?.start ?? -1; // Фаза уже целиком решена (например, все согласующие одобрили, и заявка ушла // в статус «Выполнение») — добавлять в неё ещё один шаг бессмысленно, очередь // сюда больше не вернётся. Пустую фазу (rows.length===0) это не блокирует — // так можно завести самый первый шаг фазы, у которой согласующих ещё не было // вовсе. const phaseClosed = rows.length > 0 && rows.every(r => !isEditableRowStatus(r.status)); const applyReorder = (mutate: (rows: Row[]) => Row[]): void => { const renumbered = withOrdersFrom(mutate(withParallel), 0); const updates = diffOrders(rows, renumbered); if (updates.length > 0) onReorder(updates); }; const moveBlockUp = (blockIndex: number): void => { if (blockIndex <= lastLockedBlockIndex + 1) return; // упёрлись в заблокированный блок applyReorder(current => { const a = blocks[blockIndex - 1]; const b = blocks[blockIndex]; return [...current.slice(0, a.start), ...current.slice(b.start, b.end + 1), ...current.slice(a.start, a.end + 1), ...current.slice(b.end + 1)]; }); }; const moveBlockDown = (blockIndex: number): void => { if (blockIndex >= blocks.length - 1) return; applyReorder(current => { const a = blocks[blockIndex]; const b = blocks[blockIndex + 1]; return [...current.slice(0, a.start), ...current.slice(b.start, b.end + 1), ...current.slice(a.start, a.end + 1), ...current.slice(b.end + 1)]; }); }; const toggleParallel = (rowIndex: number): void => { if (rowIndex <= 0 || rowIndex === firstMovableRowIndex) return; applyReorder(current => current.map((r, i) => (i === rowIndex ? { ...r, parallelWithPrev: !r.parallelWithPrev } : r))); }; return (
{title}
{rows.length === 0 &&
{texts.manageRequests.emptyPhase}
} {rows.length > 0 && (
{!readOnly && {withParallel.map((r, i) => { const blockIndex = blockIndexOf(blocks, i); const block = blocks[blockIndex]; const isBlockStart = block.start === i; const movable = blockIndex > lastLockedBlockIndex; const rowEditable = isEditableRowStatus(r.status); const canToggle = rowEditable && i !== firstMovableRowIndex; // Отрицательный Id — призрачная строка (добавлена на клиенте, // ответ сервера с настоящим Id ещё не пришёл, см. // ManageRequestsScreen/handleAddApprover). Трогать её нельзя: // запрос на переназначение/удаление/перестановку ушёл бы с // несуществующим для SharePoint Id. const isGhost = r.id < 0; return ( setHoveredBlock(blockIndex)} onMouseLeave={() => setHoveredBlock(undefined)} > {isBlockStart && ( )} {/* Стрелки перестановки — СЛЕВА от действий над строкой (объединить/ переназначить/удалить), одна пара на весь блок (rowSpan): двигают шаг целиком, а не отдельного участника параллельной группы. */} {!readOnly && isBlockStart && ( )} {!readOnly && ( )} ); })}
{texts.manageRequests.columnOrder} {texts.manageRequests.columnApprover} {texts.manageRequests.columnPosition} {texts.manageRequests.columnStatus} {texts.manageRequests.columnDate}} {!readOnly && }
{r.parallelWithPrev || block.end > block.start ? ( {r.order} ∥ ) : ( r.order )} {r.approverName || texts.common.empty} {r.approverEmail && {r.approverEmail}} {r.position || texts.common.empty} {r.status || texts.common.empty} {r.date || texts.common.empty} moveBlockUp(blockIndex)} /> = blocks.length - 1} onClick={() => moveBlockDown(blockIndex)} /> {isGhost ? ( {texts.manageRequests.ghostHint} ) : rowEditable ? ( toggleParallel(i)} /> onReassign(r)} /> onRemove(r)} /> ) : ( {texts.manageRequests.lockedHint} )}
)} {/* Плашка-пикер под таблицей вместо кнопки+модалки — как UserPicker в request-portal/shared/approval/ui/ApprovalMatrix.tsx: выбрал сотрудника → шаг добавлен сразу, значение поля сбрасывается для следующего добавления. Должность отдельно не спрашивается — подставляется из справочника (см. onAddApprover в ManageRequestsScreen). */} {!readOnly && phaseClosed && (
{texts.manageRequests.phaseClosedHint}
)} {!readOnly && !phaseClosed && (
)}
); };