Files
tnsu-approval-route/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx
T
gorbenko.pavel d076f4e98d
Tests / backend-test (push) Successful in 1m50s
Tests / frontend-test (push) Successful in 49s
Sla (#11)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/11
2026-08-13 04:44:08 +00:00

377 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<IManageRequestsPhaseTableProps> = ({
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 (номер шага, стрелки перестановки) физически принадлежат <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]);
// 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 (
<div className={styles.phase}>
<div className={styles.phaseHeader}>
<span className={styles.phaseTitle}>{title}</span>
</div>
{rows.length === 0 && <div className={tableStyles.wrap}><div className={tableStyles.empty}>{texts.manageRequests.emptyPhase}</div></div>}
{rows.length > 0 && (
<div className={`${tableStyles.wrap} ${styles.blockAwareWrap}`}>
<div className={tableStyles.scroll}>
<table className={tableStyles.table}>
<thead>
<tr>
<th className={styles.stepCol}>{texts.manageRequests.columnOrder}</th>
<th>{texts.manageRequests.columnApprover}</th>
<th>{texts.manageRequests.columnPosition}</th>
<th>{texts.manageRequests.columnStatus}</th>
<th>{texts.manageRequests.columnDate}</th>
<th>{texts.manageRequests.columnSla}</th>
{!readOnly && <th />}
{!readOnly && <th />}
</tr>
</thead>
<tbody>
{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 (
<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 ? (
<span className={styles.parallelMark} title={texts.manageRequests.mergeWithPrev}>
{r.order} ∥
</span>
) : (
r.order
)}
</td>
)}
<td>
<PrimaryCell>{r.approverName || texts.common.empty}</PrimaryCell>
{r.approverEmail && <CellNote>{r.approverEmail}</CellNote>}
</td>
<td>{r.position || texts.common.empty}</td>
<td><Badge variant={statusVariant(r.status)}>{r.status || texts.common.empty}</Badge></td>
<td>{r.date || texts.common.empty}</td>
{(() => {
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 (
<td className={overdue ? styles.slaOverdue : undefined}>
{formatSlaHours(hours)}
{r.slaHours === null && hours !== null && (
<CellNote>{texts.manageRequests.slaInherited}</CellNote>
)}
</td>
);
})()}
{/* Стрелки перестановки — СЛЕВА от действий над строкой (объединить/
переназначить/удалить), одна пара на весь блок (rowSpan): двигают
шаг целиком, а не отдельного участника параллельной группы. */}
{!readOnly && isBlockStart && (
<td rowSpan={block.end - block.start + 1}>
<ActionsCell>
<IconButton
icon="chevron-down"
className={`${styles.iconBtnVisible} ${styles.flipY}`}
label={texts.manageRequests.moveUp}
disabled={isGhost || !movable || blockIndex <= lastLockedBlockIndex + 1}
onClick={() => moveBlockUp(blockIndex)}
/>
<IconButton
icon="chevron-down"
className={styles.iconBtnVisible}
label={texts.manageRequests.moveDown}
disabled={isGhost || !movable || blockIndex >= blocks.length - 1}
onClick={() => moveBlockDown(blockIndex)}
/>
</ActionsCell>
</td>
)}
{!readOnly && (
<td>
{isGhost ? (
<CellNote>{texts.manageRequests.ghostHint}</CellNote>
) : rowEditable ? (
<ActionsCell>
<IconButton
icon="people"
className={r.parallelWithPrev ? styles.iconBtnActive : styles.iconBtnVisible}
label={r.parallelWithPrev ? texts.manageRequests.splitFromPrev : texts.manageRequests.mergeWithPrev}
disabled={!canToggle}
onClick={() => toggleParallel(i)}
/>
<IconButton
icon="mail"
className={styles.iconBtnVisible}
label={notifiable ? texts.manageRequests.notify : texts.manageRequests.notifyNotActive}
disabled={!notifiable || notifyingRowId === r.id}
onClick={() => onNotify(r)}
/>
<IconButton
icon="edit"
className={styles.iconBtnVisible}
label={texts.manageRequests.reassign}
onClick={() => onReassign(r)}
/>
<IconButton
icon="trash"
className={styles.iconBtnVisible}
label={texts.manageRequests.remove}
danger
onClick={() => onRemove(r)}
/>
</ActionsCell>
) : (
<CellNote>{texts.manageRequests.lockedHint}</CellNote>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* Плашка-пикер под таблицей вместо кнопки+модалки — как UserPicker в
request-portal/shared/approval/ui/ApprovalMatrix.tsx: выбрал сотрудника →
шаг добавлен сразу, значение поля сбрасывается для следующего добавления.
Должность отдельно не спрашивается — подставляется из справочника
(см. onAddApprover в ManageRequestsScreen). */}
{!readOnly && phaseClosed && (
<div className={styles.addRow}>
<CellNote>{requestRejected ? texts.manageRequests.requestRejectedHint : texts.manageRequests.phaseClosedHint}</CellNote>
</div>
)}
{!readOnly && !phaseClosed && (
<div className={styles.addRow}>
<SearchableSelect
options={addableEmployees.map(toEmployeeOption)}
value=""
loading={employeesLoading}
placeholder={addPlaceholder}
searchPlaceholder={texts.select.employeeSearchPlaceholder}
emptyMessage={texts.select.employeesEmpty}
onChange={onAddApprover}
/>
</div>
)}
</div>
);
};