Управление заявками (#7)
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:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user