From 0ffa032aded8550ffec8a38ccef1b983529b8953 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 7 Aug 2026 09:49:38 +0000 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: gorbenko.pavel Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/7 --- .gitea/workflows/test.yml | 36 +- .../components/ApprovalAdmin.tsx | 3 + .../approvalAdmin/components/app/AdminApp.tsx | 79 ++- .../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 | 286 +++++++++ .../screens/ManageRequestsScreen.module.scss | 99 +++ .../screens/ManageRequestsScreen.tsx | 567 ++++++++++++++++++ .../components/screens/RequestTypesScreen.tsx | 13 +- .../components/screens/ResolveScreen.tsx | 35 +- .../components/screens/RouteStepFormModal.tsx | 2 +- .../components/ui/Toast.module.scss | 47 ++ .../approvalAdmin/components/ui/Toast.tsx | 33 + .../approvalAdmin/components/ui/index.ts | 1 + .../hooks/useSharePointFields.ts | 4 +- .../hooks/useSharePointListTitle.ts | 12 +- .../hooks/useSharePointSiteTitle.ts | 10 +- .../approvalAdmin/models/dictionaries.ts | 5 + .../webparts/approvalAdmin/models/index.ts | 1 + .../models/requestManage.test.ts | 36 ++ .../approvalAdmin/models/requestManage.ts | 93 +++ .../approvalAdmin/models/requestTypes.ts | 1 + .../webparts/approvalAdmin/models/resolve.ts | 1 + .../models/templateRouteSteps.ts | 1 + .../approvalAdmin/models/templateRoutes.ts | 1 + .../webparts/approvalAdmin/routing.test.ts | 74 +++ .../src/webparts/approvalAdmin/routing.ts | 181 ++++++ .../approvalAdmin/services/httpClient.ts | 4 +- .../services/portalDictionary.ts | 12 +- .../services/requestManageService.ts | 308 ++++++++++ .../approvalAdmin/services/sharePointLists.ts | 24 +- frontend/src/webparts/approvalAdmin/texts.ts | 50 +- .../approvalAdmin/utils/employee.test.ts | 36 ++ .../webparts/approvalAdmin/utils/employee.ts | 2 +- .../approvalAdmin/utils/errors.test.ts | 49 ++ .../webparts/approvalAdmin/utils/errors.ts | 32 + .../approvalAdmin/utils/orderBlocks.test.ts | 114 ++++ .../approvalAdmin/utils/orderBlocks.ts | 69 +++ .../approvalAdmin/utils/stepExecutor.test.ts | 91 +++ .../approvalAdmin/utils/stepExecutor.ts | 4 +- 42 files changed, 2436 insertions(+), 65 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/components/ui/Toast.module.scss create mode 100644 frontend/src/webparts/approvalAdmin/components/ui/Toast.tsx create mode 100644 frontend/src/webparts/approvalAdmin/models/requestManage.test.ts create mode 100644 frontend/src/webparts/approvalAdmin/models/requestManage.ts create mode 100644 frontend/src/webparts/approvalAdmin/routing.test.ts create mode 100644 frontend/src/webparts/approvalAdmin/routing.ts create mode 100644 frontend/src/webparts/approvalAdmin/services/requestManageService.ts create mode 100644 frontend/src/webparts/approvalAdmin/utils/employee.test.ts create mode 100644 frontend/src/webparts/approvalAdmin/utils/errors.test.ts create mode 100644 frontend/src/webparts/approvalAdmin/utils/errors.ts create mode 100644 frontend/src/webparts/approvalAdmin/utils/orderBlocks.test.ts create mode 100644 frontend/src/webparts/approvalAdmin/utils/orderBlocks.ts create mode 100644 frontend/src/webparts/approvalAdmin/utils/stepExecutor.test.ts diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 4fce20c..372fe43 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -8,7 +8,7 @@ on: workflow_dispatch: jobs: - test: + backend-test: runs-on: ubuntu-latest steps: @@ -38,3 +38,37 @@ jobs: env: TEST_POSTGRES_CONNECTION: "Host=localhost;Port=5432;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route" run: dotnet test backend/Tnsu.ApprovalRoute.sln --no-build -c Release + + frontend-test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js 22 + uses: actions/setup-node@v3 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: | + npm config set fetch-retries 5 + npm config set fetch-retry-mintimeout 10000 + npm config set fetch-retry-maxtimeout 120000 + for i in 1 2 3; do + npm install --legacy-peer-deps && break + echo "Attempt $i failed, retrying in 20s..." + sleep 20 + done + + # heft test = build (typescript+sass+lint) и следом Jest — тот же смысл, + # что и npm run build, но без package-solution (упаковка .sppkg здесь не + # нужна, это часть deploy_frontend.yml, а не проверки на PR). + - name: Run frontend unit tests (Jest via heft) + run: npx heft test --clean 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..691b78b 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx +++ b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx @@ -5,58 +5,103 @@ 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 { navigate, useRoute } from '../../routing'; 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'); +export const AdminApp: React.FC = ({ api, dictionary, sharePoint, requestManage, currentUserEmail }) => { + const route = useRoute(); const [selectedRoute, setSelectedRoute] = React.useState(); const [selectedRequestType, setSelectedRequestType] = React.useState(); - const navigate = (key: ScreenKey): void => { - setScreen(key); - }; + const navigateTab = (key: ScreenKey): void => navigate({ tab: key }); - const openSteps = (route: TemplateRoute): void => { - setSelectedRoute(route); + const openSteps = (r: TemplateRoute): void => { + setSelectedRoute(r); setSelectedRequestType(undefined); - api.requestTypes.get(route.requestTypeId) + api.requestTypes.get(r.requestTypeId) .then(setSelectedRequestType) .catch(() => setSelectedRequestType(undefined)); - setScreen('routeSteps'); + navigate({ tab: 'routes', routeId: r.id }); }; + // Открытие по прямой ссылке / обновление страницы / кнопка «назад»: в URL + // лежит только aaroute (число), а RouteStepsScreen ждёт полный TemplateRoute — + // дочитываем его через API. Если сюда пришли через openSteps (клик в + // RoutesScreen), selectedRoute уже тот самый — повторный запрос не нужен. + React.useEffect(() => { + if (route.tab !== 'routes' || !route.routeId) { + setSelectedRoute(undefined); + setSelectedRequestType(undefined); + return; + } + if (selectedRoute && selectedRoute.id === route.routeId) return; + + let alive = true; + api.templateRoutes.get(route.routeId) + .then(r => { + if (!alive) return; + setSelectedRoute(r); + return api.requestTypes.get(r.requestTypeId).then(rt => { + if (alive) setSelectedRequestType(rt); + }); + }) + .catch(() => { + if (alive) setSelectedRoute(undefined); + }); + return () => { + alive = false; + }; + // selectedRoute — намеренно не в зависимостях: иначе повторный fetch + // запускался бы сразу после того, как этот же эффект его и выставил. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [api, route.tab, route.routeId]); + return (
- +
- {screen === 'dashboard' && } - {screen === 'requestTypes' && } - {screen === 'routes' && } - {screen === 'routeSteps' && selectedRoute && ( + {route.tab === 'dashboard' && } + {route.tab === 'requestTypes' && } + {route.tab === 'routes' && !route.routeId && ( + + )} + {route.tab === 'routes' && route.routeId && selectedRoute && ( setScreen('routes')} + onBack={() => navigate({ tab: 'routes' })} /> )} - {screen === 'resolve' && ( - + {route.tab === 'resolve' && ( + + )} + {route.tab === '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..637ed16 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx @@ -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 = ({ + title, + rows, + readOnly, + statusVariant, + onReassign, + onRemove, + employees, + employeesLoading, + addPlaceholder, + onAddApprover, + onReorder +}) => { + // Подсветка строки при наведении — на уровне БЛОКА, а не через CSS + // tbody tr:hover (см. .rowHover в ManageRequestsScreen.module.scss): ячейки + // с rowSpan (номер шага, стрелки перестановки) физически принадлежат + // только первой строки блока, поэтому обычный :hover при наведении на + // вторую/третью строку параллельного блока их не подсвечивал — получался + // "вырез" в жёлтом фоне ровно на границе объединённой ячейки. + const [hoveredBlock, setHoveredBlock] = React.useState(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 ( +
+
+ {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 ( + setHoveredBlock(blockIndex)} + onMouseLeave={() => setHoveredBlock(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..dc89086 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss @@ -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 на конкретный , а +// ячейки с rowSpan (номер шага, стрелки перестановки) физически принадлежат +// только первой строки блока — при наведении на вторую/третью строку +// параллельного блока эти ячейки не подсвечивались, получался видимый "вырез" +// в жёлтом фоне ровно на границе объединённой ячейки. Поэтому подсветка здесь +// — на уровне блока через 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; +} 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..784bba0 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx @@ -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): 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(); + 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 = ({ api, dictionary, requestManage, route }) => { + const requestTypes = useRequestTypes(api); + const employees = usePortalEmployees(dictionary); + + const [selection, setSelection] = useLocalStorageState(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(); + 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); + + 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 => { + 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 ( +
+ + + + + + +