Управление заявками (#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:
+63
@@ -0,0 +1,63 @@
|
||||
import * as React from 'react';
|
||||
import { Employee } from '../../models';
|
||||
import { texts } from '../../texts';
|
||||
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||
import { Field, FormGrid, FormModal, SearchableSelect } from '../ui';
|
||||
|
||||
export interface IManageRequestsApproverModalProps {
|
||||
title: string;
|
||||
initialEmail?: string;
|
||||
employees: Employee[];
|
||||
employeesLoading: boolean;
|
||||
onSave: (email: string, position: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Переназначение согласующего/исполнителя существующего шага (см.
|
||||
* ManageRequestsScreen). Должность НЕ вводится — берётся из справочника
|
||||
* сотрудников (api.tnsu.kz/Employee/All, см. Employee.position в
|
||||
* models/dictionaries.ts) на момент сохранения, отдельного поля для неё
|
||||
* в этой форме нет. Добавление НОВОГО шага через модалку не идёт — см.
|
||||
* плашку-пикер под таблицей в ManageRequestsPhaseTable.
|
||||
*/
|
||||
export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalProps> = ({
|
||||
title,
|
||||
initialEmail = '',
|
||||
employees,
|
||||
employeesLoading,
|
||||
onSave,
|
||||
onClose
|
||||
}) => {
|
||||
const [email, setEmail] = React.useState(initialEmail);
|
||||
const submit = useAsyncSubmit();
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
const trimmedEmail = email.trim();
|
||||
if (!trimmedEmail) {
|
||||
submit.fail(texts.manageRequests.validationApprover);
|
||||
return;
|
||||
}
|
||||
const position = findEmployeeByEmail(employees, trimmedEmail)?.position || '';
|
||||
submit.run(() => onSave(trimmedEmail, position));
|
||||
};
|
||||
|
||||
return (
|
||||
<FormModal title={title} error={submit.error} isSaving={submit.isBusy} onSubmit={handleSubmit} onClose={onClose}>
|
||||
<FormGrid>
|
||||
<Field label={texts.manageRequests.approverLabel} required full>
|
||||
<SearchableSelect
|
||||
options={employees.map(toEmployeeOption)}
|
||||
value={email}
|
||||
loading={employeesLoading}
|
||||
placeholder={texts.manageRequests.approverPlaceholder}
|
||||
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||
emptyMessage={texts.select.employeesEmpty}
|
||||
onChange={setEmail}
|
||||
/>
|
||||
</Field>
|
||||
</FormGrid>
|
||||
</FormModal>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.matrix {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.phase {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.phase:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.phaseHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.phaseTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.requestHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
// Колонка с номером шага — узкая, содержимое по центру (важно при rowSpan на
|
||||
// параллельный блок: иначе номер прижимался бы к верхней строке блока).
|
||||
.stepCol {
|
||||
width: 56px;
|
||||
white-space: nowrap;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.parallelMark {
|
||||
color: $gold-600;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.flipY {
|
||||
svg { transform: scaleY(-1); }
|
||||
}
|
||||
|
||||
// Подсветка строки при наведении здесь — своя, НЕ штатный DataTable.module.scss
|
||||
// tbody tr:hover: та подсветка вешается через :hover на конкретный <tr>, а
|
||||
// ячейки с rowSpan (номер шага, стрелки перестановки) физически принадлежат
|
||||
// <tr> только первой строки блока — при наведении на вторую/третью строку
|
||||
// параллельного блока эти ячейки не подсвечивались, получался видимый "вырез"
|
||||
// в жёлтом фоне ровно на границе объединённой ячейки. Поэтому подсветка здесь
|
||||
// — на уровне блока через React-состояние (см. hoveredBlock в
|
||||
// ManageRequestsPhaseTable), а не через CSS :hover, а штатный tr:hover для
|
||||
// этой конкретной таблицы отключаем, чтобы они не накладывались друг на друга.
|
||||
.blockAwareWrap :global(tbody tr:hover) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.rowHover td {
|
||||
background: $gold-50;
|
||||
}
|
||||
|
||||
// IconButton по умолчанию (Button.module.scss/.iconButton) прозрачная — рамка
|
||||
// появляется только по hover. В этой таблице кнопок много и часть из них не
|
||||
// самоочевидна по смыслу (параллельность, перестановка шага), поэтому здесь
|
||||
// рамка видна всегда — иначе выглядит как текст, а не как элементы управления.
|
||||
// !important — переопределяет .iconButton независимо от порядка подключения
|
||||
// стилей (это два разных CSS-модуля, порядок их правил в бандле не гарантирован).
|
||||
.iconBtnVisible {
|
||||
border-color: $gray-300 !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
// Включённое состояние кнопки «объединить с предыдущим шагом» — в отличие от
|
||||
// остальных кнопок это переключатель состояния, а не разовое действие,
|
||||
// поэтому оно залито (как в request-portal/shared/approval/ui/approval.module.scss/.iconBtnActive).
|
||||
// !important по той же причине, что и у .iconBtnVisible.
|
||||
.iconBtnActive {
|
||||
border-color: $gold-500 !important;
|
||||
background: $gold-500 !important;
|
||||
color: $navy-950 !important;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $gold-600 !important;
|
||||
background: $gold-600 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Плашка-пикер под таблицей для добавления шага (вместо кнопки+модалки) —
|
||||
// повторяет request-portal/shared/approval/ui/ApprovalMatrix.tsx/.matrixPicker.
|
||||
.addRow {
|
||||
margin-top: 10px;
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
import * as React from 'react';
|
||||
import styles from './ManageRequestsScreen.module.scss';
|
||||
import { ManageRequestsApproverModal } from './ManageRequestsApproverModal';
|
||||
import { ManageRequestsPhaseTable } from './ManageRequestsPhaseTable';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||
import { RequestManageService } from '../../services/requestManageService';
|
||||
import {
|
||||
APPROVAL_PHASE,
|
||||
ManagedApprovalRow,
|
||||
ManagedRequestMatrix,
|
||||
ManagedRequestSummary,
|
||||
REQUEST_STATUS,
|
||||
isFullyClosedRequestStatus
|
||||
} from '../../models';
|
||||
import { texts } from '../../texts';
|
||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
import { findEmployeeByEmail } from '../../utils/employee';
|
||||
import { reportError } from '../../utils/errors';
|
||||
import { markParallel, withOrdersFrom } from '../../utils/orderBlocks';
|
||||
import { IManageRequestRouteParams, replaceRoute } from '../../routing';
|
||||
import {
|
||||
Alert,
|
||||
AsyncSearchableSelect,
|
||||
Badge,
|
||||
BadgeVariant,
|
||||
Breadcrumbs,
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
FormGrid,
|
||||
PageHeader,
|
||||
Panel,
|
||||
PrimaryCell,
|
||||
Select,
|
||||
Toast
|
||||
} from '../ui';
|
||||
|
||||
export interface IManageRequestsScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||
dictionary?: PortalDictionaryClient;
|
||||
requestManage: RequestManageService;
|
||||
/** Заполнение из URL (глубокая ссылка на конкретную заявку) — есть, только
|
||||
* если в адресе заданы aamtype/aamreq (см. routing.ts). */
|
||||
route?: IManageRequestRouteParams;
|
||||
}
|
||||
|
||||
// Цвет плашки статуса — те же группы смысла, что и в request-portal
|
||||
// (StatusBadge.tsx): решено положительно/отрицательно/ожидает/черновик.
|
||||
function statusVariant(status: string): BadgeVariant {
|
||||
if (status === 'Согласовано' || status === 'Выполнено' || status === 'Закрыта') return 'ok';
|
||||
if (status === 'Отклонено') return 'red';
|
||||
if (status === 'Возвращена на доработку') return 'gold';
|
||||
if (status === 'Черновик') return 'neutral';
|
||||
return 'blue'; // Ждёт очереди / На согласовании / На исполнении / Выполнение
|
||||
}
|
||||
|
||||
// Новый шаг всегда встаёт ПОСЛЕ всех существующих строк фазы — в т.ч. после уже
|
||||
// решённых. Перестановка (ManageRequestsPhaseTable) двигает существующие шаги,
|
||||
// но НИКОГДА не пускает их раньше последнего уже решённого блока — см.
|
||||
// utils/orderBlocks.ts. Вместе эти два правила и дают гарантию «нельзя
|
||||
// поставить согласующего выше того, кто уже одобрил заявку».
|
||||
function nextOrder(phaseRows: ManagedApprovalRow[]): number {
|
||||
return phaseRows.length > 0 ? Math.max(...phaseRows.map(r => r.order)) + 1 : 1;
|
||||
}
|
||||
|
||||
function patchRow(matrix: ManagedRequestMatrix, rowId: number, patch: Partial<ManagedApprovalRow>): ManagedRequestMatrix {
|
||||
return { ...matrix, rows: matrix.rows.map(r => (r.id === rowId ? { ...r, ...patch } : r)) };
|
||||
}
|
||||
|
||||
// После удаления шага у ОСТАВШИХСЯ шагов той же фазы order пересчитывается
|
||||
// заново по порядку (1..N), а не остаётся с дыркой на месте удалённого — та же
|
||||
// логика перестановки, что и в ManageRequestsPhaseTable (withOrdersFrom),
|
||||
// только вызывается отсюда, т.к. удаление живёт на уровне экрана. Renumber'ит
|
||||
// ВСЮ фазу, включая уже решённые строки: их order на активацию очереди больше
|
||||
// не влияет (activateNextQueued смотрит только на queued/onApproval/
|
||||
// onExecution), так что перенумеровать их безопасно — а без этого дырка от
|
||||
// удалённого решённого шага так и осталась бы видна в колонке «Шаг».
|
||||
function renumberPhase(phaseRows: ManagedApprovalRow[]): Array<{ id: number; order: number }> {
|
||||
const sorted = [...phaseRows].sort((a, b) => a.order - b.order);
|
||||
const renumbered = withOrdersFrom(markParallel(sorted), 0);
|
||||
return renumbered
|
||||
.filter((r, i) => r.order !== sorted[i].order)
|
||||
.map(r => ({ id: r.id, order: r.order }));
|
||||
}
|
||||
|
||||
// Строки в одном из этих статусов ещё «в игре» — решение по ним не принято.
|
||||
// approved/executed/rejected/returned сюда не входят: это уже принятые решения
|
||||
// (в т.ч. «Возвращена на доработку» — согласующий своё слово сказал, просто мы
|
||||
// разрешили его потом переназначить; в очереди на активацию он не участвует).
|
||||
const PENDING_STATUSES: string[] = [REQUEST_STATUS.queued, REQUEST_STATUS.onApproval, REQUEST_STATUS.onExecution];
|
||||
|
||||
// Пересчитывает «чья сейчас очередь» в фазе — 1:1 activateNextQueued из
|
||||
// request-portal (shared/approval/approvalList.service.ts): среди ещё не
|
||||
// решённых строк у той, что с МИНИМАЛЬНЫМ order, выставляется активный статус
|
||||
// фазы («На согласовании»/«На исполнении»), у всех остальных — «Ждёт очереди».
|
||||
// Несколько строк с одинаковым order (параллельный шаг) активируются вместе —
|
||||
// в этом и смысл параллельности. Вызывается после ЛЮБОГО изменения, которое
|
||||
// могло поменять, кто в фазе «первый»: перестановка, удаление, добавление.
|
||||
function computeActivationUpdates(phaseRows: ManagedApprovalRow[], phase: string): Array<{ id: number; status: string }> {
|
||||
const activeStatus = phase === APPROVAL_PHASE.execution ? REQUEST_STATUS.onExecution : REQUEST_STATUS.onApproval;
|
||||
const pending = phaseRows.filter(r => PENDING_STATUSES.indexOf(r.status) !== -1);
|
||||
if (pending.length === 0) return [];
|
||||
const minOrder = Math.min(...pending.map(r => r.order));
|
||||
const updates: Array<{ id: number; status: string }> = [];
|
||||
pending.forEach(r => {
|
||||
const desired = r.order === minOrder ? activeStatus : REQUEST_STATUS.queued;
|
||||
if (r.status !== desired) updates.push({ id: r.id, status: desired });
|
||||
});
|
||||
return updates;
|
||||
}
|
||||
|
||||
// Сливает изменения order и status по одному и тому же id в один объект — иначе
|
||||
// пришлось бы отправлять на одну и ту же строку два отдельных PATCH-запроса.
|
||||
function mergeRowUpdates(
|
||||
orderUpdates: Array<{ id: number; order: number }>,
|
||||
statusUpdates: Array<{ id: number; status: string }>
|
||||
): Array<{ id: number; order?: number; status?: string }> {
|
||||
const byId = new Map<number, { id: number; order?: number; status?: string }>();
|
||||
orderUpdates.forEach(u => byId.set(u.id, { ...(byId.get(u.id) || { id: u.id }), order: u.order }));
|
||||
statusUpdates.forEach(u => byId.set(u.id, { ...(byId.get(u.id) || { id: u.id }), status: u.status }));
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
// Что выбрано в комбобоксах «Тип заявки»/«Заявка» — переживает перезагрузку
|
||||
// страницы (localStorage), чтобы не искать одну и ту же заявку заново после
|
||||
// каждого F5. selectedRequest хранится целиком (не только id), чтобы сразу
|
||||
// показать подпись в AsyncSearchableSelect без похода в API на восстановление.
|
||||
interface IPersistedSelection {
|
||||
requestTypeId: string;
|
||||
selectedRequest: ManagedRequestSummary | undefined;
|
||||
}
|
||||
|
||||
const EMPTY_SELECTION: IPersistedSelection = { requestTypeId: '', selectedRequest: undefined };
|
||||
const SELECTION_STORAGE_KEY = 'approvalAdmin.manageRequestsSelection';
|
||||
|
||||
// Раз в 2 минуты тихо (без «Загрузка…» на весь экран) перечитываем матрицу
|
||||
// открытой заявки — на неё мог повлиять кто-то другой (согласующий принял
|
||||
// решение через request-portal), а вкладка могла быть открыта давно.
|
||||
const AUTO_REFRESH_MS = 2 * 60 * 1000;
|
||||
|
||||
export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api, dictionary, requestManage, route }) => {
|
||||
const requestTypes = useRequestTypes(api);
|
||||
const employees = usePortalEmployees(dictionary);
|
||||
|
||||
const [selection, setSelection] = useLocalStorageState<IPersistedSelection>(SELECTION_STORAGE_KEY, EMPTY_SELECTION);
|
||||
const { requestTypeId, selectedRequest } = selection;
|
||||
|
||||
// Открыли по глубокой ссылке (aamtype/aamreq в URL) — перекрывает то, что
|
||||
// было в localStorage. Только на монтировании — см. тот же приём в
|
||||
// ResolveScreen (route меняется при каждом собственном replaceRoute ниже).
|
||||
// title/status заявки в URL не хранятся (их полностью определяет requestId) —
|
||||
// ставим пустыми, реальные подтянутся, как только матрица загрузится (см.
|
||||
// reloadMatrix ниже: там selection.selectedRequest патчится из matrix.request).
|
||||
React.useEffect(() => {
|
||||
if (!route) return;
|
||||
setSelection({
|
||||
requestTypeId: route.requestTypeId,
|
||||
selectedRequest: { id: Number(route.requestId), title: '', status: '', created: '', authorName: '' }
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Держим URL синхронным с выбором — открытую заявку можно скопировать и
|
||||
// прислать коллеге. replaceRoute, а не navigate: смена заявки — это правка
|
||||
// состояния текущего экрана, а не переход на новую страницу с отдельным
|
||||
// шагом в истории браузера. title/status заявки в URL не пишем — их выводит
|
||||
// сам requestId, хранить производные данные в ссылке незачем.
|
||||
React.useEffect(() => {
|
||||
replaceRoute({
|
||||
tab: 'manageRequests',
|
||||
manage: selectedRequest ? { requestTypeId, requestId: String(selectedRequest.id) } : undefined
|
||||
});
|
||||
}, [requestTypeId, selectedRequest]);
|
||||
const [matrix, setMatrix] = React.useState<ManagedRequestMatrix | undefined>();
|
||||
const [matrixLoading, setMatrixLoading] = React.useState(false);
|
||||
const [matrixError, setMatrixError] = React.useState<string | undefined>();
|
||||
const [modal, setModal] = React.useState<ManagedApprovalRow | undefined>();
|
||||
const [deleteRow, setDeleteRow] = React.useState<ManagedApprovalRow | undefined>();
|
||||
const [saveError, setSaveError] = React.useState<string | undefined>();
|
||||
// Отрицательные Id — «призрачные» строки: добавлены на клиенте, ответ сервера
|
||||
// (и настоящий Id из SharePoint) ещё не долетел. Реальные Id списка всегда
|
||||
// положительные, поэтому пересечься эти диапазоны не могут.
|
||||
const nextGhostId = React.useRef(-1);
|
||||
|
||||
const requestType = requestTypes.items.filter(rt => rt.id === Number(requestTypeId))[0];
|
||||
// Только типы с привязанным списком SharePoint — без list_id/site_url искать
|
||||
// и читать заявки этого типа отсюда просто негде.
|
||||
const manageableRequestTypes = requestTypes.items.filter(rt => rt.listId && rt.siteUrl);
|
||||
|
||||
const selectRequestType = (id: string): void => {
|
||||
// Заявка выбрана для ДРУГОГО типа — сбрасываем, иначе комбобокс продолжит
|
||||
// показывать заявку не того типа, что выбран сейчас.
|
||||
setSelection({ requestTypeId: id, selectedRequest: undefined });
|
||||
setMatrix(undefined);
|
||||
setMatrixError(undefined);
|
||||
};
|
||||
|
||||
const selectRequest = (item: ManagedRequestSummary | undefined): void =>
|
||||
setSelection(prev => ({ ...prev, selectedRequest: item }));
|
||||
|
||||
const searchRequests = React.useCallback(
|
||||
(query: string, page: number) => {
|
||||
if (!requestType) return Promise.resolve({ items: [], hasMore: false });
|
||||
return requestManage.searchRequests(requestType, query, page);
|
||||
},
|
||||
[requestManage, requestType]
|
||||
);
|
||||
|
||||
// silent=true — фоновое обновление (после своего же действия, автообновление
|
||||
// каждые 2 мин): матрица подменяется на новую БЕЗ показа полноэкранного
|
||||
// «Загрузка…», чтобы клик по кнопке не выглядел как перезагрузка страницы.
|
||||
// silent=false — только при выборе ДРУГОЙ заявки/типа: там показывать
|
||||
// загрузку нормально, экран и так пуст.
|
||||
const reloadMatrix = React.useCallback(
|
||||
(silent = false) => {
|
||||
if (!requestType || !selectedRequest) return;
|
||||
if (!silent) setMatrixLoading(true);
|
||||
setMatrixError(undefined);
|
||||
requestManage
|
||||
.getMatrix(requestType, selectedRequest.id)
|
||||
.then(m => {
|
||||
setMatrix(m);
|
||||
// title/status в комбобоксе (а значит, и в localStorage) держим
|
||||
// синхронными с сервером — особенно важно после захода по глубокой
|
||||
// ссылке, где изначально они пустые (см. эффект-затравку выше).
|
||||
//
|
||||
// ВАЖНО: возвращаем ТОТ ЖЕ prev (без изменений), если title/status не
|
||||
// отличаются — m.request это каждый раз НОВЫЙ объект (свежий парсинг
|
||||
// ответа), и если писать его в состояние безусловно, selectedRequest
|
||||
// получает новую ссылку на КАЖДОЙ загрузке, даже когда содержимое не
|
||||
// поменялось. Это меняет зависимости reloadMatrix → пересоздаёт саму
|
||||
// функцию → мониторящий её useEffect срабатывает заново → снова
|
||||
// reloadMatrix() → снова новая ссылка — бесконечный цикл перезагрузки
|
||||
// (именно так выглядела «неадекватно долгая загрузка»).
|
||||
setSelection(prev => {
|
||||
const sel = prev.selectedRequest;
|
||||
if (!sel || sel.id !== m.request.id) return prev;
|
||||
const same = sel.title === m.request.title && sel.status === m.request.status;
|
||||
return same ? prev : { ...prev, selectedRequest: m.request };
|
||||
});
|
||||
})
|
||||
.catch((e: Error) => setMatrixError(reportError(e, 'reloadMatrix: getMatrix')))
|
||||
.finally(() => {
|
||||
if (!silent) setMatrixLoading(false);
|
||||
});
|
||||
},
|
||||
[requestManage, requestType, selectedRequest]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMatrix(undefined);
|
||||
reloadMatrix();
|
||||
}, [reloadMatrix]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
if (!document.hidden) reloadMatrix(true);
|
||||
}, AUTO_REFRESH_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [reloadMatrix]);
|
||||
|
||||
// Удаление — строка убирается из матрицы СРАЗУ по подтверждению, не дожидаясь
|
||||
// ответа сервера (запрос уходит фоном). Если он всё же упадёт — строку
|
||||
// возвращаем обратно и показываем ошибку. useDeleteTarget тут не подходит:
|
||||
// тот хук держит диалог открытым со спиннером до ответа сервера, а нужно
|
||||
// ровно наоборот — закрыть диалог и убрать строку мгновенно.
|
||||
const askDelete = (row: ManagedApprovalRow): void => setDeleteRow(row);
|
||||
const cancelDelete = (): void => setDeleteRow(undefined);
|
||||
|
||||
const confirmDelete = (): void => {
|
||||
if (!matrix || !deleteRow) return;
|
||||
const row = deleteRow;
|
||||
setDeleteRow(undefined);
|
||||
setSaveError(undefined);
|
||||
|
||||
const previous = matrix;
|
||||
const remainingPhaseRows = matrix.rows.filter(r => r.phase === row.phase && r.id !== row.id);
|
||||
const renumberUpdates = renumberPhase(remainingPhaseRows);
|
||||
const orderById = new Map(renumberUpdates.map(u => [u.id, u.order]));
|
||||
const renumberedPhaseRows = remainingPhaseRows.map(r => (orderById.has(r.id) ? { ...r, order: orderById.get(r.id)! } : r));
|
||||
|
||||
// Удалили как раз ту строку, что была активной («На согласовании» и т.п.) —
|
||||
// очередь надо продвинуть на следующую по order, иначе фаза так и останется
|
||||
// без единой активной строки.
|
||||
const activationUpdates = computeActivationUpdates(renumberedPhaseRows, row.phase);
|
||||
const statusById = new Map(activationUpdates.map(u => [u.id, u.status]));
|
||||
const writeUpdates = mergeRowUpdates(renumberUpdates, activationUpdates);
|
||||
|
||||
setMatrix({
|
||||
...matrix,
|
||||
rows: matrix.rows
|
||||
.filter(r => r.id !== row.id)
|
||||
.map(r => {
|
||||
const order = orderById.get(r.id);
|
||||
const status = statusById.get(r.id);
|
||||
return order !== undefined || status !== undefined ? { ...r, ...(order !== undefined && { order }), ...(status !== undefined && { status }) } : r;
|
||||
})
|
||||
});
|
||||
|
||||
requestManage
|
||||
.removeRow(matrix, row.id)
|
||||
.then(() => {
|
||||
if (writeUpdates.length === 0) return;
|
||||
// Удаление уже прошло — откатывать нечего, даже если пересохранение
|
||||
// номеров/статусов не удастся. Просто сообщаем об ошибке: строка
|
||||
// гарантированно удалена, а нумерация/активация в SharePoint в худшем
|
||||
// случае разойдётся с тем, что видно на экране (починится обычной
|
||||
// перестановкой любого шага той же фазы).
|
||||
return requestManage.reorderRows(matrix, writeUpdates).catch((e: Error) => setSaveError(reportError(e, 'confirmDelete: reorderRows')));
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
setMatrix(previous);
|
||||
setSaveError(reportError(e, 'confirmDelete: removeRow'));
|
||||
});
|
||||
};
|
||||
|
||||
// Заявка полностью закрыта — маршрут только для просмотра целиком, независимо
|
||||
// от статуса отдельных строк (см. isFullyClosedRequestStatus).
|
||||
const readOnly = matrix ? isFullyClosedRequestStatus(matrix.request.status) : true;
|
||||
|
||||
// Сортировка по order — ОБЯЗАТЕЛЬНА, а не косметика: ManageRequestsPhaseTable
|
||||
// считает блоки и параллельность по ФИЗИЧЕСКОМУ порядку строк в массиве
|
||||
// (markParallel сравнивает order с предыдущей строкой), а оптимистичные
|
||||
// обновления (handleReorder/confirmDelete) патчут только значения order,
|
||||
// оставляя порядок элементов массива прежним. Без пересортировки после
|
||||
// первой же перестановки массив перестаёт быть отсортированным, и следующий
|
||||
// клик по стрелке даёт пустой diffOrders — то есть кнопка молча перестаёт
|
||||
// работать до перезагрузки матрицы с сервера.
|
||||
//
|
||||
// Сортировка стабильная (ES2019+), поэтому строки параллельного шага
|
||||
// (одинаковый order) сохраняют относительный порядок.
|
||||
const sortByOrder = (rows: ManagedApprovalRow[]): ManagedApprovalRow[] => [...rows].sort((a, b) => a.order - b.order);
|
||||
|
||||
const approvalRows = matrix ? sortByOrder(matrix.rows.filter(r => r.phase !== APPROVAL_PHASE.execution)) : [];
|
||||
const executionRows = matrix ? sortByOrder(matrix.rows.filter(r => r.phase === APPROVAL_PHASE.execution)) : [];
|
||||
|
||||
const openReassign = (row: ManagedApprovalRow): void => setModal(row);
|
||||
const closeModal = (): void => setModal(undefined);
|
||||
|
||||
// Переназначение — тоже оптимистично: patch конкретной строки сразу по ответу
|
||||
// сервера, без похода за всей матрицей заново. approverName берём из уже
|
||||
// загруженного справочника сотрудников (тот же источник, что подставляет
|
||||
// должность, — см. ManageRequestsApproverModal), чтобы не делать лишний
|
||||
// запрос только за отображаемым именем.
|
||||
const saveModal = (email: string, position: string): Promise<void> => {
|
||||
if (!matrix || !modal) return Promise.resolve();
|
||||
setSaveError(undefined);
|
||||
const rowId = modal.id;
|
||||
return requestManage
|
||||
.reassignApprover(matrix, rowId, email, position)
|
||||
.then(() => {
|
||||
const found = findEmployeeByEmail(employees.items, email);
|
||||
setMatrix(prev =>
|
||||
prev ? patchRow(prev, rowId, { approverEmail: email, approverName: found?.fio || email, position }) : prev
|
||||
);
|
||||
closeModal();
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
setSaveError(reportError(e, 'saveModal: reassignApprover'));
|
||||
throw e;
|
||||
});
|
||||
};
|
||||
|
||||
// Добавление нового шага — плашка-пикер под таблицей (ManageRequestsPhaseTable),
|
||||
// без модалки (как в request-portal). Строка появляется в матрице СРАЗУ, с
|
||||
// временным отрицательным Id («призрачная» — см. nextGhostId и isGhostRow в
|
||||
// ManageRequestsPhaseTable, там ей запрещены переназначение/удаление/
|
||||
// перестановка, пока она не подтверждена сервером). Настоящий Id из
|
||||
// SharePoint долетает следом через тихую фоновую перезагрузку, которая
|
||||
// подменяет призрачную строку настоящей. Если запрос упадёт — просто
|
||||
// убираем призрак и показываем ошибку.
|
||||
const handleAddApprover = (phase: string, email: string): void => {
|
||||
if (!matrix) return;
|
||||
setSaveError(undefined);
|
||||
const phaseRows = phase === APPROVAL_PHASE.execution ? executionRows : approvalRows;
|
||||
const found = findEmployeeByEmail(employees.items, email);
|
||||
const position = found?.position || '';
|
||||
const order = nextOrder(phaseRows);
|
||||
const ghostId = nextGhostId.current--;
|
||||
|
||||
// Новая строка встаёт ПОСЛЕДНЕЙ по order (см. nextOrder) — значит, стать
|
||||
// активной сразу она может, только если во всей фазе вообще нет ни одной
|
||||
// ожидающей строки (например, первый исполнитель, добавленный в заявку, где
|
||||
// до этого фазы исполнения не было вовсе): тогда «первый по order среди
|
||||
// ожидающих» — она и есть. Если в фазе уже кто-то ждёт своей очереди, та
|
||||
// строка всегда раньше по order — новая просто встаёт в конец «Ждёт очереди».
|
||||
const hasPending = phaseRows.some(r => PENDING_STATUSES.indexOf(r.status) !== -1);
|
||||
const activeStatus = phase === APPROVAL_PHASE.execution ? REQUEST_STATUS.onExecution : REQUEST_STATUS.onApproval;
|
||||
const initialStatus = hasPending ? REQUEST_STATUS.queued : activeStatus;
|
||||
|
||||
const ghostRow: ManagedApprovalRow = {
|
||||
id: ghostId,
|
||||
ticketId: matrix.request.id,
|
||||
approverId: null,
|
||||
approverName: found?.fio || email,
|
||||
approverEmail: email,
|
||||
position,
|
||||
status: initialStatus,
|
||||
date: '',
|
||||
comment: '',
|
||||
order,
|
||||
allowReassign: false,
|
||||
phase
|
||||
};
|
||||
|
||||
const previous = matrix;
|
||||
setMatrix({ ...matrix, rows: [...matrix.rows, ghostRow] });
|
||||
|
||||
requestManage
|
||||
.addRow(matrix, matrix.request.id, phase, order, initialStatus, email, position, false)
|
||||
.then(() => reloadMatrix(true))
|
||||
.catch((e: Error) => {
|
||||
setMatrix(previous);
|
||||
setSaveError(reportError(e, 'handleAddApprover: addRow'));
|
||||
});
|
||||
};
|
||||
|
||||
// Перестановка/параллельность — оптимистично и БЕЗ блокировки: order из
|
||||
// ManageRequestsPhaseTable применяется к матрице СРАЗУ (каждый клик по
|
||||
// стрелке/значку виден мгновенно, в т.ч. при быстрых повторных кликах —
|
||||
// предыдущий фоновый запрос ничего не блокирует), запрос на запись уходит
|
||||
// фоном. При ошибке — не откатываем к «состоянию до клика»: если кликов было
|
||||
// несколько подряд, такой откат затёр бы более поздние, ещё не сохранённые
|
||||
// изменения. Вместо этого просто перечитываем матрицу с сервера — она сама
|
||||
// покажет, что в итоге реально сохранилось.
|
||||
const handleReorder = (updates: Array<{ id: number; order: number }>): void => {
|
||||
if (!matrix || updates.length === 0) return;
|
||||
setSaveError(undefined);
|
||||
|
||||
const orderById = new Map(updates.map(u => [u.id, u.order]));
|
||||
const nextRows = matrix.rows.map(r => (orderById.has(r.id) ? { ...r, order: orderById.get(r.id)! } : r));
|
||||
|
||||
// order-обновления всегда относятся к ОДНОЙ фазе (перестановка идёт внутри
|
||||
// одной таблицы — см. ManageRequestsPhaseTable), поэтому фазу для пересчёта
|
||||
// активации достаточно взять у любой из задетых строк.
|
||||
const changedPhase = nextRows.filter(r => orderById.has(r.id))[0].phase;
|
||||
const activationUpdates = computeActivationUpdates(nextRows.filter(r => r.phase === changedPhase), changedPhase);
|
||||
const statusById = new Map(activationUpdates.map(u => [u.id, u.status]));
|
||||
|
||||
setMatrix({
|
||||
...matrix,
|
||||
rows: nextRows.map(r => (statusById.has(r.id) ? { ...r, status: statusById.get(r.id)! } : r))
|
||||
});
|
||||
|
||||
const writeUpdates = mergeRowUpdates(updates, activationUpdates);
|
||||
requestManage.reorderRows(matrix, writeUpdates).catch((e: Error) => {
|
||||
setSaveError(reportError(e, 'handleReorder: reorderRows'));
|
||||
reloadMatrix(true);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.manageRequests.breadcrumb }]} />
|
||||
<PageHeader icon="clipboard" subtitle={texts.manageRequests.subtitle} title={texts.manageRequests.title} />
|
||||
|
||||
<Panel>
|
||||
<FormGrid>
|
||||
<Field
|
||||
label={texts.manageRequests.requestTypeLabel}
|
||||
required
|
||||
hint={!requestTypes.isLoading && manageableRequestTypes.length === 0 ? texts.manageRequests.requestTypeEmpty : undefined}
|
||||
>
|
||||
<Select
|
||||
options={manageableRequestTypes.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||
placeholder={texts.manageRequests.requestTypePlaceholder}
|
||||
value={requestTypeId}
|
||||
onChange={e => selectRequestType(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.manageRequests.requestLabel} required hint={!requestType ? texts.manageRequests.requestDisabledHint : undefined}>
|
||||
<AsyncSearchableSelect<ManagedRequestSummary>
|
||||
key={requestTypeId || 'none'}
|
||||
value={selectedRequest ? String(selectedRequest.id) : ''}
|
||||
selectedLabel={selectedRequest ? `#${selectedRequest.id} ${selectedRequest.title}` : undefined}
|
||||
selectedSublabel={selectedRequest?.status}
|
||||
disabled={!requestType}
|
||||
placeholder={texts.manageRequests.requestPlaceholder}
|
||||
searchPlaceholder={texts.manageRequests.requestSearchPlaceholder}
|
||||
search={searchRequests}
|
||||
getValue={r => String(r.id)}
|
||||
getLabel={r => `#${r.id} ${r.title}`}
|
||||
getSublabel={r => r.status}
|
||||
onChange={(_, item) => selectRequest(item)}
|
||||
/>
|
||||
</Field>
|
||||
</FormGrid>
|
||||
</Panel>
|
||||
|
||||
{requestTypes.error && <Alert kind="err">{requestTypes.error}</Alert>}
|
||||
{matrixError && <Alert kind="err">{texts.manageRequests.loadError}: {matrixError}</Alert>}
|
||||
|
||||
{matrixLoading && <Panel>{texts.common.loading}</Panel>}
|
||||
|
||||
{!matrixLoading && matrix && (
|
||||
<Panel>
|
||||
<div className={styles.requestHeader}>
|
||||
<PrimaryCell>{matrix.request.title}</PrimaryCell>
|
||||
<Badge variant={statusVariant(matrix.request.status)}>{matrix.request.status || texts.common.empty}</Badge>
|
||||
</div>
|
||||
|
||||
{readOnly && <Alert kind="warn">{texts.manageRequests.fullyClosedNotice}</Alert>}
|
||||
{!matrix.approvalListTitle && <Alert kind="warn">{texts.manageRequests.noApprovalList}</Alert>}
|
||||
|
||||
{matrix.approvalListTitle && (
|
||||
<div className={styles.matrix}>
|
||||
<ManageRequestsPhaseTable
|
||||
title={texts.manageRequests.approvalPhaseTitle}
|
||||
rows={approvalRows}
|
||||
readOnly={readOnly}
|
||||
statusVariant={statusVariant}
|
||||
onReassign={openReassign}
|
||||
onRemove={askDelete}
|
||||
employees={employees.items}
|
||||
employeesLoading={employees.isLoading}
|
||||
addPlaceholder={texts.manageRequests.addApproverPlaceholder}
|
||||
onAddApprover={email => handleAddApprover(APPROVAL_PHASE.approval, email)}
|
||||
onReorder={handleReorder}
|
||||
/>
|
||||
<ManageRequestsPhaseTable
|
||||
title={texts.manageRequests.executionPhaseTitle}
|
||||
rows={executionRows}
|
||||
readOnly={readOnly}
|
||||
statusVariant={statusVariant}
|
||||
onReassign={openReassign}
|
||||
onRemove={askDelete}
|
||||
employees={employees.items}
|
||||
employeesLoading={employees.isLoading}
|
||||
addPlaceholder={texts.manageRequests.addExecutorPlaceholder}
|
||||
onAddApprover={email => handleAddApprover(APPROVAL_PHASE.execution, email)}
|
||||
onReorder={handleReorder}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{modal && (
|
||||
<ManageRequestsApproverModal
|
||||
title={texts.manageRequests.reassignTitle}
|
||||
initialEmail={modal.approverEmail}
|
||||
employees={employees.items}
|
||||
employeesLoading={employees.isLoading}
|
||||
onSave={saveModal}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteRow && (
|
||||
<ConfirmDialog
|
||||
title={texts.manageRequests.deleteTitle}
|
||||
text={texts.manageRequests.deleteText(deleteRow.position)}
|
||||
confirmLabel={texts.common.remove}
|
||||
onCancel={cancelDelete}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{saveError && (
|
||||
<Toast message={`${texts.manageRequests.saveError}: ${saveError}`} onClose={() => setSaveError(undefined)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -42,15 +42,20 @@ interface IFormState {
|
||||
|
||||
const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', listId: '' };
|
||||
|
||||
// siteUrl/listId — поля RequestType, зеркалящие nullable JSON-контракт бэкенда
|
||||
// (см. models/requestTypes.ts) — здесь сразу приводим null к undefined,
|
||||
// внутренний код (хуки/кэш SharePointListsClient) с null не работает нигде.
|
||||
/** Ячейка «Сайт SharePoint» — подтягивает заголовок сайта по url, хранящемуся на бэкенде. */
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- siteUrl зеркалит RequestType.siteUrl (nullable JSON-контракт бэкенда)
|
||||
const SiteUrlCell: React.FC<{ client: SharePointListsClient; siteUrl: string | null }> = ({ client, siteUrl }) => {
|
||||
const { title } = useSharePointSiteTitle(client, siteUrl);
|
||||
const { title } = useSharePointSiteTitle(client, siteUrl ?? undefined);
|
||||
return <>{siteUrl ? formatSiteLabel(title, siteUrl) : texts.common.empty}</>;
|
||||
};
|
||||
|
||||
/** Ячейка «Список SharePoint» — на бэкенде хранится id списка (устойчив к переименованию), тут подтягиваем Title для читаемости. */
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- siteUrl/listId зеркалят RequestType (nullable JSON-контракт бэкенда)
|
||||
const ListIdCell: React.FC<{ client: SharePointListsClient; siteUrl: string | null; listId: string | null }> = ({ client, siteUrl, listId }) => {
|
||||
const { title } = useSharePointListTitle(client, siteUrl, listId);
|
||||
const { title } = useSharePointListTitle(client, siteUrl ?? undefined, listId ?? undefined);
|
||||
return <>{listId ? (title ?? listId) : texts.common.empty}</>;
|
||||
};
|
||||
|
||||
@@ -104,7 +109,11 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
||||
name: form.name.trim(),
|
||||
pageUrl: form.pageUrl.trim(),
|
||||
section: form.section.trim(),
|
||||
// null, а не undefined — контракт CreateRequestTypeInput зеркалит
|
||||
// nullable-поля бэкенда (см. models/requestTypes.ts).
|
||||
// eslint-disable-next-line @rushstack/no-new-null
|
||||
siteUrl: siteUrl || null,
|
||||
// eslint-disable-next-line @rushstack/no-new-null
|
||||
listId: listId || null
|
||||
};
|
||||
const request = editing ? api.requestTypes.update(editing.id, input) : api.requestTypes.create(input);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||
import { useSharePointFields } from '../../hooks/useSharePointFields';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
||||
import { IResolveRouteParams, replaceRoute } from '../../routing';
|
||||
import {
|
||||
Alert,
|
||||
AsyncSearchableSelect,
|
||||
@@ -35,6 +36,9 @@ export interface IResolveScreenProps {
|
||||
dictionary?: PortalDictionaryClient;
|
||||
sharePoint: SharePointListsClient;
|
||||
currentUserEmail?: string;
|
||||
/** Заполнение из URL (глубокая ссылка) — есть, только если в адресе заданы
|
||||
* параметры проверки маршрута (aartype и т.д., см. routing.ts). */
|
||||
route?: IResolveRouteParams;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
|
||||
@@ -63,14 +67,39 @@ const EMPTY_RESOLVE_FORM: IResolveFormState = {
|
||||
fieldValues: []
|
||||
};
|
||||
|
||||
export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary, sharePoint, currentUserEmail }) => {
|
||||
export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary, sharePoint, currentUserEmail, route }) => {
|
||||
const requestTypes = useRequestTypes(api);
|
||||
const [form, setForm] = useLocalStorageState<IResolveFormState>(RESOLVE_FORM_STORAGE_KEY, EMPTY_RESOLVE_FORM);
|
||||
const { requestTypeId, userEmail, projectId, projectLabel, status, fieldValues = [] } = form;
|
||||
|
||||
// Открыли по глубокой ссылке (aartype и т.д. в URL) — параметры оттуда
|
||||
// перекрывают то, что было в localStorage. Только на монтировании: route
|
||||
// меняется при каждом собственном же replaceRoute ниже, а не только при
|
||||
// переходе по внешней ссылке — сравнивать его на равенство и держать в
|
||||
// зависимостях смысла нет.
|
||||
React.useEffect(() => {
|
||||
if (!route) return;
|
||||
setForm({
|
||||
requestTypeId: route.requestTypeId,
|
||||
userEmail: route.userEmail,
|
||||
projectId: route.projectId,
|
||||
projectLabel: route.projectLabel,
|
||||
status: route.status,
|
||||
fieldValues: route.fieldValues
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Держим URL синхронным с формой — правки сразу становятся частью ссылки,
|
||||
// которую можно скопировать/сохранить. replaceRoute, а не navigate: иначе
|
||||
// каждый введённый символ добавлял бы шаг в историю браузера.
|
||||
React.useEffect(() => {
|
||||
replaceRoute({ tab: 'resolve', resolve: { requestTypeId, userEmail, projectId, projectLabel, status, fieldValues } });
|
||||
}, [requestTypeId, userEmail, projectId, projectLabel, status, fieldValues]);
|
||||
|
||||
const requestType = requestTypes.items.find(rt => rt.id === Number(requestTypeId));
|
||||
const hasListId = Boolean(requestType?.listId);
|
||||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl, requestType?.listId);
|
||||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined);
|
||||
const fieldNameOptions = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
|
||||
|
||||
const resolved = useResolveRoute(api);
|
||||
@@ -243,7 +272,7 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
||||
key: 'executor',
|
||||
header: texts.resolve.columnExecutor,
|
||||
render: r => {
|
||||
const employee = findEmployeeByEmail(employees.items, r.executorEmail);
|
||||
const employee = findEmployeeByEmail(employees.items, r.executorEmail ?? undefined);
|
||||
return (
|
||||
<div>
|
||||
<PrimaryCell>{employee ? employee.fio : (r.executorEmail ?? texts.common.empty)}</PrimaryCell>
|
||||
|
||||
@@ -113,7 +113,7 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo));
|
||||
const submit = useAsyncSubmit();
|
||||
const hasListId = Boolean(requestType?.listId);
|
||||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl, requestType?.listId);
|
||||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined);
|
||||
|
||||
const fieldNameOptions = React.useMemo(() => {
|
||||
const options = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
|
||||
|
||||
Reference in New Issue
Block a user