From 7ef4f53a6af58e8643ec23e62ee4573df5285bb1 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 7 Aug 2026 11:06:15 +0500 Subject: [PATCH 1/7] =?UTF-8?q?=D0=BA=D1=83=D1=87=D0=B0=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/ApprovalAdmin.tsx | 3 + .../approvalAdmin/components/app/AdminApp.tsx | 40 +- .../components/app/TopNav.module.scss | 17 +- .../approvalAdmin/components/app/TopNav.tsx | 3 +- .../approvalAdmin/components/app/types.ts | 2 +- .../screens/ManageRequestsApproverModal.tsx | 63 +++ .../screens/ManageRequestsPhaseTable.tsx | 267 +++++++++++ .../screens/ManageRequestsScreen.module.scss | 73 +++ .../screens/ManageRequestsScreen.tsx | 442 ++++++++++++++++++ .../approvalAdmin/models/dictionaries.ts | 4 + .../webparts/approvalAdmin/models/index.ts | 1 + .../approvalAdmin/models/requestManage.ts | 92 ++++ .../services/portalDictionary.ts | 11 +- .../services/requestManageService.ts | 293 ++++++++++++ frontend/src/webparts/approvalAdmin/texts.ts | 50 +- .../approvalAdmin/utils/orderBlocks.ts | 69 +++ 16 files changed, 1417 insertions(+), 13 deletions(-) create mode 100644 frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsApproverModal.tsx create mode 100644 frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx create mode 100644 frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss create mode 100644 frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx create mode 100644 frontend/src/webparts/approvalAdmin/models/requestManage.ts create mode 100644 frontend/src/webparts/approvalAdmin/services/requestManageService.ts create mode 100644 frontend/src/webparts/approvalAdmin/utils/orderBlocks.ts diff --git a/frontend/src/webparts/approvalAdmin/components/ApprovalAdmin.tsx b/frontend/src/webparts/approvalAdmin/components/ApprovalAdmin.tsx index 63902c0..5f18669 100644 --- a/frontend/src/webparts/approvalAdmin/components/ApprovalAdmin.tsx +++ b/frontend/src/webparts/approvalAdmin/components/ApprovalAdmin.tsx @@ -6,6 +6,7 @@ import { ApprovalRouteApi } from '../services/api'; import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient'; import { PortalDictionaryClient } from '../services/portalDictionary'; import { SharePointListsClient } from '../services/sharePointLists'; +import { RequestManageService } from '../services/requestManageService'; import { texts } from '../texts'; const FONTS_LINK_ID = 'tnsu-approval-admin-fonts'; @@ -32,6 +33,7 @@ const ApprovalAdmin: React.FC = props => { const [dictionary, setDictionary] = React.useState(); const [error, setError] = React.useState(); const sharePoint = React.useMemo(() => new SharePointListsClient(props.context), [props.context]); + const requestManage = React.useMemo(() => new RequestManageService(props.context), [props.context]); React.useEffect(() => { ensureFontsLoaded(); @@ -83,6 +85,7 @@ const ApprovalAdmin: React.FC = props => { api={api} dictionary={dictionary} sharePoint={sharePoint} + requestManage={requestManage} currentUserEmail={props.context.pageContext.user.email} /> )} diff --git a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx index 474e25b..ec86c28 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx +++ b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx @@ -5,26 +5,59 @@ import { ScreenKey } from './types'; import { ApprovalRouteApi } from '../../services/api'; import { PortalDictionaryClient } from '../../services/portalDictionary'; import { SharePointListsClient } from '../../services/sharePointLists'; +import { RequestManageService } from '../../services/requestManageService'; import { RequestType, TemplateRoute } from '../../models'; import { DashboardScreen } from '../screens/DashboardScreen'; import { RequestTypesScreen } from '../screens/RequestTypesScreen'; import { RoutesScreen } from '../screens/RoutesScreen'; import { RouteStepsScreen } from '../screens/RouteStepsScreen'; import { ResolveScreen } from '../screens/ResolveScreen'; +import { ManageRequestsScreen } from '../screens/ManageRequestsScreen'; export interface IAdminAppProps { api: ApprovalRouteApi; /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ dictionary?: PortalDictionaryClient; sharePoint: SharePointListsClient; + requestManage: RequestManageService; currentUserEmail?: string; } -export const AdminApp: React.FC = ({ api, dictionary, sharePoint, currentUserEmail }) => { - const [screen, setScreen] = React.useState('dashboard'); +const ACTIVE_TAB_STORAGE_KEY = 'approvalAdmin.activeTab'; + +// 'routeSteps' сознательно НЕ входит: этот экран требует selectedRoute — полный +// объект маршрута, полученный по клику из RoutesScreen, а не просто ключ. После +// перезагрузки страницы такого объекта нет и взять его неоткуда, поэтому эту +// вкладку как «последнюю открытую» восстанавливать нельзя — если она вдруг +// окажется в localStorage (старое значение, ручная правка), откатываемся на дашборд. +const RESTORABLE_SCREENS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'manageRequests']; + +function readStoredScreen(): ScreenKey { + try { + const raw = window.localStorage.getItem(ACTIVE_TAB_STORAGE_KEY); + if (raw && (RESTORABLE_SCREENS as string[]).indexOf(raw) !== -1) return raw as ScreenKey; + } catch { + // localStorage недоступен (приватный режим и т.п.) — просто не восстанавливаем. + } + return 'dashboard'; +} + +export const AdminApp: React.FC = ({ api, dictionary, sharePoint, requestManage, currentUserEmail }) => { + const [screen, setScreenState] = React.useState(readStoredScreen); const [selectedRoute, setSelectedRoute] = React.useState(); const [selectedRequestType, setSelectedRequestType] = React.useState(); + // 'routeSteps' в localStorage не пишем — см. RESTORABLE_SCREENS. + const setScreen = (key: ScreenKey): void => { + setScreenState(key); + if (key === 'routeSteps') return; + try { + window.localStorage.setItem(ACTIVE_TAB_STORAGE_KEY, key); + } catch { + // не критично — просто не запомнится + } + }; + const navigate = (key: ScreenKey): void => { setScreen(key); }; @@ -58,6 +91,9 @@ export const AdminApp: React.FC = ({ api, dictionary, sharePoint {screen === 'resolve' && ( )} + {screen === 'manageRequests' && ( + + )} ); diff --git a/frontend/src/webparts/approvalAdmin/components/app/TopNav.module.scss b/frontend/src/webparts/approvalAdmin/components/app/TopNav.module.scss index f283b56..b5bf541 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/TopNav.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/app/TopNav.module.scss @@ -19,7 +19,7 @@ display: flex; align-items: center; gap: 10px; - margin-right: 28px; + margin-right: 20px; flex-shrink: 0; } @@ -45,17 +45,20 @@ // бы от body и прокрутка уезжала бы не туда. position: relative; - // Полоса прокрутки на тёмной шапке выглядит инородно, скрываем её — - // вкладки остаются доступны свайпом/колесом. - scrollbar-width: none; - -ms-overflow-style: none; - &::-webkit-scrollbar { display: none; } + // Тонкая полупрозрачная полоса — не спрятана полностью: с добавлением новых + // вкладок (сейчас их 6) они не всегда помещаются в узкую панель, и без + // видимого скроллбара непонятно, что часть вкладок обрезана, а не пропала. + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, .35) transparent; + &::-webkit-scrollbar { height: 4px; } + &::-webkit-scrollbar-track { background: transparent; } + &::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, .35); border-radius: 2px; } } .tab { flex-shrink: 0; white-space: nowrap; - padding: 0 16px; + padding: 0 13px; border: none; background: 0; color: rgba(255,255,255,.5); diff --git a/frontend/src/webparts/approvalAdmin/components/app/TopNav.tsx b/frontend/src/webparts/approvalAdmin/components/app/TopNav.tsx index 506ef05..2a33140 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/TopNav.tsx +++ b/frontend/src/webparts/approvalAdmin/components/app/TopNav.tsx @@ -12,7 +12,8 @@ const TABS: ITab[] = [ { key: 'dashboard', label: texts.nav.dashboard }, { key: 'requestTypes', label: texts.nav.requestTypes }, { key: 'routes', label: texts.nav.routes }, - { key: 'resolve', label: texts.nav.resolve } + { key: 'resolve', label: texts.nav.resolve }, + { key: 'manageRequests', label: texts.nav.manageRequests } ]; export interface ITopNavProps { diff --git a/frontend/src/webparts/approvalAdmin/components/app/types.ts b/frontend/src/webparts/approvalAdmin/components/app/types.ts index f7f7876..d6b53d8 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/types.ts +++ b/frontend/src/webparts/approvalAdmin/components/app/types.ts @@ -1 +1 @@ -export type ScreenKey = 'dashboard' | 'requestTypes' | 'routes' | 'routeSteps' | 'resolve'; +export type ScreenKey = 'dashboard' | 'requestTypes' | 'routes' | 'routeSteps' | 'resolve' | 'manageRequests'; diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsApproverModal.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsApproverModal.tsx new file mode 100644 index 0000000..93e14dc --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsApproverModal.tsx @@ -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; + onClose: () => void; +} + +/** + * Переназначение согласующего/исполнителя существующего шага (см. + * ManageRequestsScreen). Должность НЕ вводится — берётся из справочника + * сотрудников (api.tnsu.kz/Employee/All, см. Employee.position в + * models/dictionaries.ts) на момент сохранения, отдельного поля для неё + * в этой форме нет. Добавление НОВОГО шага через модалку не идёт — см. + * плашку-пикер под таблицей в ManageRequestsPhaseTable. + */ +export const ManageRequestsApproverModal: React.FC = ({ + 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 ( + + + + + + + + ); +}; diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx new file mode 100644 index 0000000..daad771 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx @@ -0,0 +1,267 @@ +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; + busy: 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 = ({ + title, + rows, + readOnly, + busy, + statusVariant, + onReassign, + onRemove, + employees, + employeesLoading, + addPlaceholder, + onAddApprover, + onReorder +}) => { + 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] + ); + // Индекс последнего заблокированного блока — все блоки ПОСЛЕ него движимы, + // все блоки НА НЁМ И РАНЬШЕ — нет (см. комментарий к компоненту). + const lastLockedBlockIndex = lockedBlock.reduce((acc, locked, i) => (locked ? i : acc), -1); + 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 ( +
+
+ {title} +
+ + {rows.length === 0 &&
{texts.manageRequests.emptyPhase}
} + + {rows.length > 0 && ( +
+
+ + + + + + + + + {!readOnly && + + + {withParallel.map((r, i) => { + const blockIndex = blockIndexOf(blocks, i); + const block = blocks[blockIndex]; + const isBlockStart = block.start === i; + const movable = blockIndex > lastLockedBlockIndex; + const rowEditable = isEditableRowStatus(r.status); + const canToggle = rowEditable && i !== firstMovableRowIndex; + // Отрицательный Id — призрачная строка (добавлена на клиенте, + // ответ сервера с настоящим Id ещё не пришёл, см. + // ManageRequestsScreen/handleAddApprover). Трогать её нельзя: + // запрос на переназначение/удаление/перестановку ушёл бы с + // несуществующим для SharePoint Id. + const isGhost = r.id < 0; + + return ( + block.start ? styles.parallelRow : undefined}> + {isBlockStart && ( + + )} + + + + + {/* Стрелки перестановки — СЛЕВА от действий над строкой (объединить/ + переназначить/удалить), одна пара на весь блок (rowSpan): двигают + шаг целиком, а не отдельного участника параллельной группы. */} + {!readOnly && isBlockStart && ( + + )} + {!readOnly && ( + + )} + + ); + })} + +
{texts.manageRequests.columnOrder}{texts.manageRequests.columnApprover}{texts.manageRequests.columnPosition}{texts.manageRequests.columnStatus}{texts.manageRequests.columnDate}} + {!readOnly && } +
+ {r.parallelWithPrev || block.end > block.start ? ( + + {r.order} ∥ + + ) : ( + r.order + )} + + {r.approverName || texts.common.empty} + {r.approverEmail && {r.approverEmail}} + {r.position || texts.common.empty}{r.status || texts.common.empty}{r.date || texts.common.empty} + + moveBlockUp(blockIndex)} + /> + = blocks.length - 1} + onClick={() => moveBlockDown(blockIndex)} + /> + + + {isGhost ? ( + {texts.manageRequests.ghostHint} + ) : rowEditable ? ( + + toggleParallel(i)} + /> + onReassign(r)} + /> + onRemove(r)} + /> + + ) : ( + {texts.manageRequests.lockedHint} + )} +
+
+
+ )} + + {/* Плашка-пикер под таблицей вместо кнопки+модалки — как UserPicker в + request-portal/shared/approval/ui/ApprovalMatrix.tsx: выбрал сотрудника → + шаг добавлен сразу, значение поля сбрасывается для следующего добавления. + Должность отдельно не спрашивается — подставляется из справочника + (см. onAddApprover в ManageRequestsScreen). */} + {!readOnly && phaseClosed && ( +
+ {texts.manageRequests.phaseClosedHint} +
+ )} + + {!readOnly && !phaseClosed && ( +
+ +
+ )} +
+ ); +}; diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss new file mode 100644 index 0000000..153dfa3 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss @@ -0,0 +1,73 @@ +@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; +} + +// Строки одного параллельного блока — лёгкая заливка, чтобы объединённая +// ячейка «Шаг» читалась как один блок, а не как случайно схлопнутые строки. +.parallelRow > td { + background: rgba($gold-500, .06); +} + +.flipY { + svg { transform: scaleY(-1); } +} + +// IconButton по умолчанию (Button.module.scss/.iconButton) прозрачная — рамка +// появляется только по hover. В этой таблице кнопок много и часть из них не +// самоочевидна по смыслу (параллельность, перестановка шага), поэтому здесь +// рамка видна всегда — иначе выглядит как текст, а не как элементы управления. +// !important — переопределяет .iconButton независимо от порядка подключения +// стилей (это два разных CSS-модуля, порядок их правил в бандле не гарантирован). +.iconBtnVisible { + border-color: $gray-300 !important; + background: #fff !important; +} + +// Плашка-пикер под таблицей для добавления шага (вместо кнопки+модалки) — +// повторяет request-portal/shared/approval/ui/ApprovalMatrix.tsx/.matrixPicker. +.addRow { + margin-top: 10px; +} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx new file mode 100644 index 0000000..10a2a88 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx @@ -0,0 +1,442 @@ +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 { markParallel, withOrdersFrom } from '../../utils/orderBlocks'; +import { + Alert, + AsyncSearchableSelect, + Badge, + BadgeVariant, + Breadcrumbs, + ConfirmDialog, + Field, + FormGrid, + PageHeader, + Panel, + PrimaryCell, + Select +} from '../ui'; + +export interface IManageRequestsScreenProps { + api: ApprovalRouteApi; + /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ + dictionary?: PortalDictionaryClient; + requestManage: RequestManageService; +} + +// Цвет плашки статуса — те же группы смысла, что и в 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): 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 })); +} + +// Что выбрано в комбобоксах «Тип заявки»/«Заявка» — переживает перезагрузку +// страницы (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 = ({ api, dictionary, requestManage }) => { + const requestTypes = useRequestTypes(api); + const employees = usePortalEmployees(dictionary); + + const [selection, setSelection] = useLocalStorageState(SELECTION_STORAGE_KEY, EMPTY_SELECTION); + const { requestTypeId, selectedRequest } = selection; + const [matrix, setMatrix] = React.useState(); + const [matrixLoading, setMatrixLoading] = React.useState(false); + const [matrixError, setMatrixError] = React.useState(); + const [modal, setModal] = React.useState(); + const [deleteRow, setDeleteRow] = React.useState(); + const [saveError, setSaveError] = React.useState(); + // Отрицательные Id — «призрачные» строки: добавлены на клиенте, ответ сервера + // (и настоящий Id из SharePoint) ещё не долетел. Реальные Id списка всегда + // положительные, поэтому пересечься эти диапазоны не могут. + const nextGhostId = React.useRef(-1); + // Идёт фоновая запись (перестановка/переключение параллельности) — блокирует + // ТОЛЬКО повторную перестановку, чтобы не отправить второй reorder поверх ещё + // не завершённого (порядок мог бы разъехаться при гонке двух запросов). + // Переназначение/удаление/добавление такой блокировки не требуют: они не + // трогают order соседних строк, поэтому гонок между собой не создают. + const [reordering, setReordering] = React.useState(false); + + 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(setMatrix) + .catch((e: Error) => setMatrixError(e.message)) + .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])); + + setMatrix({ + ...matrix, + rows: matrix.rows + .filter(r => r.id !== row.id) + .map(r => (orderById.has(r.id) ? { ...r, order: orderById.get(r.id)! } : r)) + }); + + requestManage + .removeRow(matrix, row.id) + .then(() => { + if (renumberUpdates.length === 0) return; + // Удаление уже прошло — откатывать нечего, даже если это пересохранение + // номеров не удастся. Просто сообщаем об ошибке: строка гарантированно + // удалена, а нумерация в SharePoint в худшем случае останется с дыркой + // (что чинится обычной перестановкой любого шага той же фазы). + return requestManage.reorderRows(matrix, renumberUpdates).catch((e: Error) => setSaveError(e.message)); + }) + .catch((e: Error) => { + setMatrix(previous); + setSaveError(e.message); + }); + }; + + // Заявка полностью закрыта — маршрут только для просмотра целиком, независимо + // от статуса отдельных строк (см. isFullyClosedRequestStatus). + const readOnly = matrix ? isFullyClosedRequestStatus(matrix.request.status) : true; + + const approvalRows = matrix ? matrix.rows.filter(r => r.phase !== APPROVAL_PHASE.execution) : []; + const executionRows = matrix ? 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 => { + 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(e.message); + 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--; + + // Новая строка всегда «Ждёт очереди» — request-portal переводит её в «На + // согласовании»/«На исполнении» через activateNextQueued при следующем + // решении согласующего или повторной отправке формы (см. + // request-portal/shared/approval/approvalList.service.ts). Если это ПЕРВЫЙ + // шаг фазы (например, добавили исполнителя в заявку, где фазы исполнения + // раньше не было вовсе) — activateNextQueued не сработает сам по себе, пока + // кто-то не примет решение по заявке; шаг до этого момента просто ждёт. + const ghostRow: ManagedApprovalRow = { + id: ghostId, + ticketId: matrix.request.id, + approverId: null, + approverName: found?.fio || email, + approverEmail: email, + position, + status: REQUEST_STATUS.queued, + date: '', + comment: '', + order, + allowReassign: false, + phase + }; + + const previous = matrix; + setMatrix({ ...matrix, rows: [...matrix.rows, ghostRow] }); + + requestManage + .addRow(matrix, matrix.request.id, phase, order, REQUEST_STATUS.queued, email, position, false) + .then(() => reloadMatrix(true)) + .catch((e: Error) => { + setMatrix(previous); + setSaveError(e.message); + }); + }; + + // Перестановка/параллельность — оптимистично: order из ManageRequestsPhaseTable + // применяется к матрице СРАЗУ (клик по стрелке/значку виден мгновенно), запрос + // на запись уходит фоном. Если он упадёт — откатываем к состоянию до клика и + // показываем ошибку; если пройдёт — тихо перечитываем матрицу (не меняя + // видимого состояния, если её никто больше не менял, но подхватит, если менял). + const handleReorder = (updates: Array<{ id: number; order: number }>): void => { + if (!matrix || reordering) return; + setSaveError(undefined); + + const previous = matrix; + const orderById = new Map(updates.map(u => [u.id, u.order])); + setMatrix({ ...matrix, rows: matrix.rows.map(r => (orderById.has(r.id) ? { ...r, order: orderById.get(r.id)! } : r)) }); + + setReordering(true); + requestManage + .reorderRows(matrix, updates) + .then(() => reloadMatrix(true)) + .catch((e: Error) => { + setMatrix(previous); + setSaveError(e.message); + }) + .finally(() => setReordering(false)); + }; + + return ( +
+ + + + + + +