import * as React from 'react'; import tableStyles from '../ui/DataTable.module.scss'; import styles from './ManageRequestsScreen.module.scss'; import { Employee, ManagedApprovalRow, REQUEST_STATUS, isEditableRowStatus, isMovableRowStatus } 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'; import { effectiveSlaHours, formatSlaHours, isRowOverdue } from '../../utils/sla'; export interface IManageRequestsPhaseTableProps { title: string; /** Строки ОДНОЙ фазы, уже отсортированные по order (см. ManageRequestsScreen). */ rows: ManagedApprovalRow[]; /** * ВСЕ строки заявки (обе фазы) — нужны для расчёта момента активации шага * (см. utils/sla.ts/activatedAt): первый шаг исполнения отсчитывается от * последнего решения фазы согласования, а той фазы в `rows` этой таблицы нет. */ allRows: ManagedApprovalRow[]; /** Дата создания заявки (ISO) — точка отсчёта для самого первого шага согласования. */ requestCreatedIso: string; /** SLA типа заявки в часах — то, что действует, если на строке своё значение не задано. */ // eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда requestTypeSlaHours: number | null; /** Заявка целиком только для просмотра (закрыта) — контролов нет вообще. */ readOnly: boolean; /** * Заявка отклонена целиком (REQUEST_STATUS.rejected) — маршрут дальше не * продолжится ни в одной фазе, поэтому добавлять исполнителя нельзя НИГДЕ, * даже в фазу, где сейчас вообще нет строк (иначе rows.length===0 не даёт * phaseClosed сработать по строкам — см. phaseClosed ниже). readOnly здесь * не подходит: он делает всю заявку read-only целиком (для isFullyClosed — * REQUEST_STATUS.closed), а отклонённую заявку можно продолжать * редактировать (переназначать/удалять уже существующие шаги), просто * новые добавлять больше некуда. */ requestRejected: boolean; statusVariant: (status: string) => BadgeVariant; onReassign: (row: ManagedApprovalRow) => void; onRemove: (row: ManagedApprovalRow) => void; onNotify: (row: ManagedApprovalRow) => void; /** Id строки, для которой сейчас в процессе отправка уведомления — кнопка на ней отключена. */ notifyingRowId?: number; 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) — такие блоки «заблокированы» * и никогда не двигаются и не участвуют в переключении параллельности (см. * isMovableRowStatus в models/requestManage.ts). Блокировка распространяется на * все блоки НЕ ПОЗЖЕ последнего заблокированного — по построению маршрута (шаги * решаются по возрастанию order) заблокированные блоки всегда образуют * непрерывный префикс, поэтому «после последнего заблокированного» и есть та * самая граница, ниже которой перестановка запрещена (см. utils/orderBlocks.ts). * * Важно: «заблокирован для перестановки» ≠ «нельзя редактировать». Возвращённый * на доработку шаг двигать нельзя (решение по нему уже принято), но человека на * нём переназначить всё ещё можно — это две разные проверки (isMovableRowStatus * для перестановки/параллельности, isEditableRowStatus для переназначения и * удаления, см. rowEditable ниже). * * Переназначение/удаление СТРОКИ (не блока) разрешено независимо от соседей — * если у одного из двух параллельных согласующих решение уже принято, а у * другого нет, второго всё ещё можно переназначить. */ export const ManageRequestsPhaseTable: React.FC = ({ title, rows, allRows, requestCreatedIso, requestTypeSlaHours, readOnly, requestRejected, statusVariant, onReassign, onRemove, onNotify, notifyingRowId, 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]); // isMovableRowStatus, а НЕ isEditableRowStatus: «Возвращена на доработку» — // редактируемый статус (переназначить можно), но НЕ движимый — своё слово по // шагу уже сказано, менять его место в маршруте нельзя (см. models/requestManage.ts). const lockedBlock = React.useMemo( () => blocks.map(b => withParallel.slice(b.start, b.end + 1).some(r => !isMovableRowStatus(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) это не блокирует — // так можно завести самый первый шаг фазы, у которой согласующих ещё не было // вовсе. requestRejected — отдельная причина закрытости, которая, В ОТЛИЧИЕ // от строк выше, обязана блокировать добавление даже в ПУСТУЮ фазу: если // заявку отклонили на согласовании, до фазы исполнения дело уже не дойдёт, // хотя строк там могло вообще не быть (см. скрин — «Исполнение» пустая, но // добавлять туда исполнителя всё равно бессмысленно). const phaseClosed = requestRejected || (rows.length > 0 && rows.every(r => !isEditableRowStatus(r.status))); // Один и тот же человек не может стоять в фазе дважды (в т.ч. призрачной // строкой, которая ещё не подтверждена сервером, — см. handleAddApprover в // ManageRequestsScreen) — исключаем уже занятых из пикера, а не проверяем // постфактум: так дубликат физически нельзя выбрать. const usedEmails = new Set(rows.map(r => r.approverEmail.toLowerCase()).filter(Boolean)); const addableEmployees = employees.filter(e => !usedEmails.has(e.email.toLowerCase())); 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); // Уведомлять можно только активный шаг (флоу шлёт письмо тому, // чей RowID мы передали, независимо от статуса — сам он не // проверяет очередь). «Ждёт очереди» — шаг ещё не наступил, // слать по нему нечего: согласующий/исполнитель пока не // должен действовать (см. request-portal/notify.service.ts, // где по той же причине уведомляется только активный шаг). const notifiable = r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution; // Переключение параллельности — тоже перестановка (меняет order // относительно соседней строки), поэтому смотрим на isMovableRowStatus, // а не на rowEditable: у «Возвращена на доработку» кнопка // объединения должна быть недоступна, хотя переназначить/удалить // такую строку всё ещё можно (см. ветку ниже, где rowEditable решает, // показывать ActionsCell целиком или lockedHint). const canToggle = isMovableRowStatus(r.status) && i !== firstMovableRowIndex; // Отрицательный Id — призрачная строка (добавлена на клиенте, // ответ сервера с настоящим Id ещё не пришёл, см. // ManageRequestsScreen/handleAddApprover). Трогать её нельзя: // запрос на переназначение/удаление/перестановку ушёл бы с // несуществующим для SharePoint Id. const isGhost = r.id < 0; return ( setHoveredBlock(blockIndex)} onMouseLeave={() => setHoveredBlock(undefined)} > {isBlockStart && ( )} {(() => { const hours = effectiveSlaHours(r, requestTypeSlaHours); // Просроченным может быть только шаг, у которого прямо сейчас // идёт очередь — решённый уже не тикает, ждущий ещё не начался. const overdue = (r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution) && isRowOverdue(r, allRows, requestCreatedIso, requestTypeSlaHours, new Date()); return ( ); })()} {/* Стрелки перестановки — СЛЕВА от действий над строкой (объединить/ переназначить/удалить), одна пара на весь блок (rowSpan): двигают шаг целиком, а не отдельного участника параллельной группы. */} {!readOnly && isBlockStart && ( )} {!readOnly && ( )} ); })}
{texts.manageRequests.columnOrder} {texts.manageRequests.columnApprover} {texts.manageRequests.columnPosition} {texts.manageRequests.columnStatus} {texts.manageRequests.columnDate} {texts.manageRequests.columnSla}} {!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} {formatSlaHours(hours)} {r.slaHours === null && hours !== null && ( {texts.manageRequests.slaInherited} )} moveBlockUp(blockIndex)} /> = blocks.length - 1} onClick={() => moveBlockDown(blockIndex)} /> {isGhost ? ( {texts.manageRequests.ghostHint} ) : rowEditable ? ( toggleParallel(i)} /> onNotify(r)} /> onReassign(r)} /> onRemove(r)} /> ) : ( {texts.manageRequests.lockedHint} )}
)} {/* Плашка-пикер под таблицей вместо кнопки+модалки — как UserPicker в request-portal/shared/approval/ui/ApprovalMatrix.tsx: выбрал сотрудника → шаг добавлен сразу, значение поля сбрасывается для следующего добавления. Должность отдельно не спрашивается — подставляется из справочника (см. onAddApprover в ManageRequestsScreen). */} {!readOnly && phaseClosed && (
{requestRejected ? texts.manageRequests.requestRejectedHint : texts.manageRequests.phaseClosedHint}
)} {!readOnly && !phaseClosed && (
)}
); };