Управление заявками (#7)
Tests / frontend-test (push) Successful in 43s
Tests / backend-test (push) Successful in 2m36s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/7
This commit was merged in pull request #7.
This commit is contained in:
2026-08-07 09:49:38 +00:00
parent 9d9f390548
commit 0ffa032ade
42 changed files with 2436 additions and 65 deletions
@@ -0,0 +1,286 @@
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<IManageRequestsPhaseTableProps> = ({
title,
rows,
readOnly,
statusVariant,
onReassign,
onRemove,
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]);
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 (
<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>
{!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);
const canToggle = rowEditable && 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>
{/* Стрелки перестановки — СЛЕВА от действий над строкой (объединить/
переназначить/удалить), одна пара на весь блок (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="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>{texts.manageRequests.phaseClosedHint}</CellNote>
</div>
)}
{!readOnly && !phaseClosed && (
<div className={styles.addRow}>
<SearchableSelect
options={employees.map(toEmployeeOption)}
value=""
loading={employeesLoading}
placeholder={addPlaceholder}
searchPlaceholder={texts.select.employeeSearchPlaceholder}
emptyMessage={texts.select.employeesEmpty}
onChange={onAddApprover}
/>
</div>
)}
</div>
);
};