Управление заявками (#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:
@@ -8,7 +8,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
backend-test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -38,3 +38,37 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
TEST_POSTGRES_CONNECTION: "Host=localhost;Port=5432;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route"
|
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
|
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
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ApprovalRouteApi } from '../services/api';
|
|||||||
import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient';
|
import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient';
|
||||||
import { PortalDictionaryClient } from '../services/portalDictionary';
|
import { PortalDictionaryClient } from '../services/portalDictionary';
|
||||||
import { SharePointListsClient } from '../services/sharePointLists';
|
import { SharePointListsClient } from '../services/sharePointLists';
|
||||||
|
import { RequestManageService } from '../services/requestManageService';
|
||||||
import { texts } from '../texts';
|
import { texts } from '../texts';
|
||||||
|
|
||||||
const FONTS_LINK_ID = 'tnsu-approval-admin-fonts';
|
const FONTS_LINK_ID = 'tnsu-approval-admin-fonts';
|
||||||
@@ -32,6 +33,7 @@ const ApprovalAdmin: React.FC<IApprovalAdminProps> = props => {
|
|||||||
const [dictionary, setDictionary] = React.useState<PortalDictionaryClient | undefined>();
|
const [dictionary, setDictionary] = React.useState<PortalDictionaryClient | undefined>();
|
||||||
const [error, setError] = React.useState<string | undefined>();
|
const [error, setError] = React.useState<string | undefined>();
|
||||||
const sharePoint = React.useMemo(() => new SharePointListsClient(props.context), [props.context]);
|
const sharePoint = React.useMemo(() => new SharePointListsClient(props.context), [props.context]);
|
||||||
|
const requestManage = React.useMemo(() => new RequestManageService(props.context), [props.context]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
ensureFontsLoaded();
|
ensureFontsLoaded();
|
||||||
@@ -83,6 +85,7 @@ const ApprovalAdmin: React.FC<IApprovalAdminProps> = props => {
|
|||||||
api={api}
|
api={api}
|
||||||
dictionary={dictionary}
|
dictionary={dictionary}
|
||||||
sharePoint={sharePoint}
|
sharePoint={sharePoint}
|
||||||
|
requestManage={requestManage}
|
||||||
currentUserEmail={props.context.pageContext.user.email}
|
currentUserEmail={props.context.pageContext.user.email}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,58 +5,103 @@ import { ScreenKey } from './types';
|
|||||||
import { ApprovalRouteApi } from '../../services/api';
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||||
import { SharePointListsClient } from '../../services/sharePointLists';
|
import { SharePointListsClient } from '../../services/sharePointLists';
|
||||||
|
import { RequestManageService } from '../../services/requestManageService';
|
||||||
import { RequestType, TemplateRoute } from '../../models';
|
import { RequestType, TemplateRoute } from '../../models';
|
||||||
|
import { navigate, useRoute } from '../../routing';
|
||||||
import { DashboardScreen } from '../screens/DashboardScreen';
|
import { DashboardScreen } from '../screens/DashboardScreen';
|
||||||
import { RequestTypesScreen } from '../screens/RequestTypesScreen';
|
import { RequestTypesScreen } from '../screens/RequestTypesScreen';
|
||||||
import { RoutesScreen } from '../screens/RoutesScreen';
|
import { RoutesScreen } from '../screens/RoutesScreen';
|
||||||
import { RouteStepsScreen } from '../screens/RouteStepsScreen';
|
import { RouteStepsScreen } from '../screens/RouteStepsScreen';
|
||||||
import { ResolveScreen } from '../screens/ResolveScreen';
|
import { ResolveScreen } from '../screens/ResolveScreen';
|
||||||
|
import { ManageRequestsScreen } from '../screens/ManageRequestsScreen';
|
||||||
|
|
||||||
export interface IAdminAppProps {
|
export interface IAdminAppProps {
|
||||||
api: ApprovalRouteApi;
|
api: ApprovalRouteApi;
|
||||||
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||||
dictionary?: PortalDictionaryClient;
|
dictionary?: PortalDictionaryClient;
|
||||||
sharePoint: SharePointListsClient;
|
sharePoint: SharePointListsClient;
|
||||||
|
requestManage: RequestManageService;
|
||||||
currentUserEmail?: string;
|
currentUserEmail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint, currentUserEmail }) => {
|
export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint, requestManage, currentUserEmail }) => {
|
||||||
const [screen, setScreen] = React.useState<ScreenKey>('dashboard');
|
const route = useRoute();
|
||||||
const [selectedRoute, setSelectedRoute] = React.useState<TemplateRoute | undefined>();
|
const [selectedRoute, setSelectedRoute] = React.useState<TemplateRoute | undefined>();
|
||||||
const [selectedRequestType, setSelectedRequestType] = React.useState<RequestType | undefined>();
|
const [selectedRequestType, setSelectedRequestType] = React.useState<RequestType | undefined>();
|
||||||
|
|
||||||
const navigate = (key: ScreenKey): void => {
|
const navigateTab = (key: ScreenKey): void => navigate({ tab: key });
|
||||||
setScreen(key);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openSteps = (route: TemplateRoute): void => {
|
const openSteps = (r: TemplateRoute): void => {
|
||||||
setSelectedRoute(route);
|
setSelectedRoute(r);
|
||||||
setSelectedRequestType(undefined);
|
setSelectedRequestType(undefined);
|
||||||
api.requestTypes.get(route.requestTypeId)
|
api.requestTypes.get(r.requestTypeId)
|
||||||
.then(setSelectedRequestType)
|
.then(setSelectedRequestType)
|
||||||
.catch(() => setSelectedRequestType(undefined));
|
.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 (
|
return (
|
||||||
<div className={styles.shell}>
|
<div className={styles.shell}>
|
||||||
<TopNav active={screen} onSelect={navigate} />
|
<TopNav active={route.tab} onSelect={navigateTab} />
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{screen === 'dashboard' && <DashboardScreen api={api} onNavigate={navigate} />}
|
{route.tab === 'dashboard' && <DashboardScreen api={api} onNavigate={navigateTab} />}
|
||||||
{screen === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
|
{route.tab === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
|
||||||
{screen === 'routes' && <RoutesScreen api={api} dictionary={dictionary} onOpenSteps={openSteps} />}
|
{route.tab === 'routes' && !route.routeId && (
|
||||||
{screen === 'routeSteps' && selectedRoute && (
|
<RoutesScreen api={api} dictionary={dictionary} onOpenSteps={openSteps} />
|
||||||
|
)}
|
||||||
|
{route.tab === 'routes' && route.routeId && selectedRoute && (
|
||||||
<RouteStepsScreen
|
<RouteStepsScreen
|
||||||
api={api}
|
api={api}
|
||||||
route={selectedRoute}
|
route={selectedRoute}
|
||||||
requestType={selectedRequestType}
|
requestType={selectedRequestType}
|
||||||
dictionary={dictionary}
|
dictionary={dictionary}
|
||||||
sharePoint={sharePoint}
|
sharePoint={sharePoint}
|
||||||
onBack={() => setScreen('routes')}
|
onBack={() => navigate({ tab: 'routes' })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{screen === 'resolve' && (
|
{route.tab === 'resolve' && (
|
||||||
<ResolveScreen api={api} dictionary={dictionary} sharePoint={sharePoint} currentUserEmail={currentUserEmail} />
|
<ResolveScreen
|
||||||
|
api={api}
|
||||||
|
dictionary={dictionary}
|
||||||
|
sharePoint={sharePoint}
|
||||||
|
currentUserEmail={currentUserEmail}
|
||||||
|
route={route.resolve}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{route.tab === 'manageRequests' && (
|
||||||
|
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-right: 28px;
|
margin-right: 20px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,17 +45,20 @@
|
|||||||
// бы от body и прокрутка уезжала бы не туда.
|
// бы от body и прокрутка уезжала бы не туда.
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
// Полоса прокрутки на тёмной шапке выглядит инородно, скрываем её —
|
// Тонкая полупрозрачная полоса — не спрятана полностью: с добавлением новых
|
||||||
// вкладки остаются доступны свайпом/колесом.
|
// вкладок (сейчас их 6) они не всегда помещаются в узкую панель, и без
|
||||||
scrollbar-width: none;
|
// видимого скроллбара непонятно, что часть вкладок обрезана, а не пропала.
|
||||||
-ms-overflow-style: none;
|
scrollbar-width: thin;
|
||||||
&::-webkit-scrollbar { display: none; }
|
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 {
|
.tab {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
padding: 0 16px;
|
padding: 0 13px;
|
||||||
border: none;
|
border: none;
|
||||||
background: 0;
|
background: 0;
|
||||||
color: rgba(255,255,255,.5);
|
color: rgba(255,255,255,.5);
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ const TABS: ITab[] = [
|
|||||||
{ key: 'dashboard', label: texts.nav.dashboard },
|
{ key: 'dashboard', label: texts.nav.dashboard },
|
||||||
{ key: 'requestTypes', label: texts.nav.requestTypes },
|
{ key: 'requestTypes', label: texts.nav.requestTypes },
|
||||||
{ key: 'routes', label: texts.nav.routes },
|
{ 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 {
|
export interface ITopNavProps {
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export type ScreenKey = 'dashboard' | 'requestTypes' | 'routes' | 'routeSteps' | 'resolve';
|
export type ScreenKey = 'dashboard' | 'requestTypes' | 'routes' | 'routeSteps' | 'resolve' | 'manageRequests';
|
||||||
|
|||||||
+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: '' };
|
const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', listId: '' };
|
||||||
|
|
||||||
|
// siteUrl/listId — поля RequestType, зеркалящие nullable JSON-контракт бэкенда
|
||||||
|
// (см. models/requestTypes.ts) — здесь сразу приводим null к undefined,
|
||||||
|
// внутренний код (хуки/кэш SharePointListsClient) с null не работает нигде.
|
||||||
/** Ячейка «Сайт SharePoint» — подтягивает заголовок сайта по url, хранящемуся на бэкенде. */
|
/** Ячейка «Сайт 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 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}</>;
|
return <>{siteUrl ? formatSiteLabel(title, siteUrl) : texts.common.empty}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Ячейка «Список SharePoint» — на бэкенде хранится id списка (устойчив к переименованию), тут подтягиваем Title для читаемости. */
|
/** Ячейка «Список 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 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}</>;
|
return <>{listId ? (title ?? listId) : texts.common.empty}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,7 +109,11 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
pageUrl: form.pageUrl.trim(),
|
pageUrl: form.pageUrl.trim(),
|
||||||
section: form.section.trim(),
|
section: form.section.trim(),
|
||||||
|
// null, а не undefined — контракт CreateRequestTypeInput зеркалит
|
||||||
|
// nullable-поля бэкенда (см. models/requestTypes.ts).
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null
|
||||||
siteUrl: siteUrl || null,
|
siteUrl: siteUrl || null,
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null
|
||||||
listId: listId || null
|
listId: listId || null
|
||||||
};
|
};
|
||||||
const request = editing ? api.requestTypes.update(editing.id, input) : api.requestTypes.create(input);
|
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 { useSharePointFields } from '../../hooks/useSharePointFields';
|
||||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||||
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
||||||
|
import { IResolveRouteParams, replaceRoute } from '../../routing';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
AsyncSearchableSelect,
|
AsyncSearchableSelect,
|
||||||
@@ -35,6 +36,9 @@ export interface IResolveScreenProps {
|
|||||||
dictionary?: PortalDictionaryClient;
|
dictionary?: PortalDictionaryClient;
|
||||||
sharePoint: SharePointListsClient;
|
sharePoint: SharePointListsClient;
|
||||||
currentUserEmail?: string;
|
currentUserEmail?: string;
|
||||||
|
/** Заполнение из URL (глубокая ссылка) — есть, только если в адресе заданы
|
||||||
|
* параметры проверки маршрута (aartype и т.д., см. routing.ts). */
|
||||||
|
route?: IResolveRouteParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
|
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
|
||||||
@@ -63,14 +67,39 @@ const EMPTY_RESOLVE_FORM: IResolveFormState = {
|
|||||||
fieldValues: []
|
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 requestTypes = useRequestTypes(api);
|
||||||
const [form, setForm] = useLocalStorageState<IResolveFormState>(RESOLVE_FORM_STORAGE_KEY, EMPTY_RESOLVE_FORM);
|
const [form, setForm] = useLocalStorageState<IResolveFormState>(RESOLVE_FORM_STORAGE_KEY, EMPTY_RESOLVE_FORM);
|
||||||
const { requestTypeId, userEmail, projectId, projectLabel, status, fieldValues = [] } = 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 requestType = requestTypes.items.find(rt => rt.id === Number(requestTypeId));
|
||||||
const hasListId = Boolean(requestType?.listId);
|
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 fieldNameOptions = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
|
||||||
|
|
||||||
const resolved = useResolveRoute(api);
|
const resolved = useResolveRoute(api);
|
||||||
@@ -243,7 +272,7 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
|||||||
key: 'executor',
|
key: 'executor',
|
||||||
header: texts.resolve.columnExecutor,
|
header: texts.resolve.columnExecutor,
|
||||||
render: r => {
|
render: r => {
|
||||||
const employee = findEmployeeByEmail(employees.items, r.executorEmail);
|
const employee = findEmployeeByEmail(employees.items, r.executorEmail ?? undefined);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PrimaryCell>{employee ? employee.fio : (r.executorEmail ?? texts.common.empty)}</PrimaryCell>
|
<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 [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo));
|
||||||
const submit = useAsyncSubmit();
|
const submit = useAsyncSubmit();
|
||||||
const hasListId = Boolean(requestType?.listId);
|
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 fieldNameOptions = React.useMemo(() => {
|
||||||
const options = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
|
const options = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// position: fixed относительно viewport — веб-часть встроена в страницу
|
||||||
|
// SharePoint, а не в отдельное модальное окно, поэтому уголок экрана, а не
|
||||||
|
// уголок какого-то контейнера.
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
right: 20px;
|
||||||
|
bottom: 20px;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: 380px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: $r-md;
|
||||||
|
background: $red-50;
|
||||||
|
color: $red-500;
|
||||||
|
border: 1px solid #FECACA;
|
||||||
|
box-shadow: $sh-3;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
animation: toastIn .15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
opacity: .6;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&:hover { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toastIn {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './Toast.module.scss';
|
||||||
|
import { Icon } from './Icon';
|
||||||
|
|
||||||
|
export interface IToastProps {
|
||||||
|
message: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTO_HIDE_MS = 6000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Всплывающее сообщение об ошибке в углу экрана — для действий, которые не
|
||||||
|
* ломают уже отрисованный экран (переназначение/перестановка/добавление шага):
|
||||||
|
* пользователь и так видит, что изменение не применилось (см. reloadMatrix
|
||||||
|
* после ошибки в ManageRequestsScreen), поэтому постоянный Alert-блок сверху
|
||||||
|
* страницы был бы избыточен. Автоматически прячется через AUTO_HIDE_MS, либо
|
||||||
|
* закрывается вручную.
|
||||||
|
*/
|
||||||
|
export const Toast: React.FC<IToastProps> = ({ message, onClose }) => {
|
||||||
|
React.useEffect(() => {
|
||||||
|
const timer = window.setTimeout(onClose, AUTO_HIDE_MS);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [message, onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.toast} role="alert">
|
||||||
|
<Icon name="warning" size="sm" />
|
||||||
|
<span className={styles.message}>{message}</span>
|
||||||
|
<button type="button" className={styles.close} onClick={onClose} aria-label="Закрыть">×</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ export * from './Button';
|
|||||||
export * from './IconButton';
|
export * from './IconButton';
|
||||||
export * from './Badge';
|
export * from './Badge';
|
||||||
export * from './Alert';
|
export * from './Alert';
|
||||||
|
export * from './Toast';
|
||||||
export * from './Panel';
|
export * from './Panel';
|
||||||
export * from './Breadcrumbs';
|
export * from './Breadcrumbs';
|
||||||
export * from './PageHeader';
|
export * from './PageHeader';
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ export interface SharePointFieldsState {
|
|||||||
/** Заголовки столбцов списка (site_url + list_name типа заявки) — источник значений для field_name шага. */
|
/** Заголовки столбцов списка (site_url + list_name типа заявки) — источник значений для field_name шага. */
|
||||||
export function useSharePointFields(
|
export function useSharePointFields(
|
||||||
client: SharePointListsClient | undefined,
|
client: SharePointListsClient | undefined,
|
||||||
siteUrl: string | undefined | null,
|
siteUrl: string | undefined,
|
||||||
listTitle: string | undefined | null
|
listTitle: string | undefined
|
||||||
): SharePointFieldsState {
|
): SharePointFieldsState {
|
||||||
const [items, setItems] = React.useState<SharePointListField[]>([]);
|
const [items, setItems] = React.useState<SharePointListField[]>([]);
|
||||||
const [isLoading, setIsLoading] = React.useState(false);
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
|
|||||||
@@ -2,22 +2,22 @@ import * as React from 'react';
|
|||||||
import { SharePointListsClient } from '../services/sharePointLists';
|
import { SharePointListsClient } from '../services/sharePointLists';
|
||||||
|
|
||||||
export interface SharePointListTitleState {
|
export interface SharePointListTitleState {
|
||||||
title: string | null;
|
title: string | undefined;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Заголовок списка SharePoint по (site_url, list_id) — для отображения «Название (id)» вместо голого GUID. */
|
/** Заголовок списка SharePoint по (site_url, list_id) — для отображения «Название (id)» вместо голого GUID. */
|
||||||
export function useSharePointListTitle(
|
export function useSharePointListTitle(
|
||||||
client: SharePointListsClient | undefined,
|
client: SharePointListsClient | undefined,
|
||||||
siteUrl: string | undefined | null,
|
siteUrl: string | undefined,
|
||||||
listId: string | undefined | null
|
listId: string | undefined
|
||||||
): SharePointListTitleState {
|
): SharePointListTitleState {
|
||||||
const [title, setTitle] = React.useState<string | null>(null);
|
const [title, setTitle] = React.useState<string | undefined>(undefined);
|
||||||
const [isLoading, setIsLoading] = React.useState(false);
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!client || !siteUrl?.trim() || !listId?.trim()) {
|
if (!client || !siteUrl?.trim() || !listId?.trim()) {
|
||||||
setTitle(null);
|
setTitle(undefined);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,7 @@ export function useSharePointListTitle(
|
|||||||
|
|
||||||
client.getListTitle(siteUrl.trim(), listId.trim())
|
client.getListTitle(siteUrl.trim(), listId.trim())
|
||||||
.then(result => { if (!cancelled) setTitle(result); })
|
.then(result => { if (!cancelled) setTitle(result); })
|
||||||
.catch(() => { if (!cancelled) setTitle(null); })
|
.catch(() => { if (!cancelled) setTitle(undefined); })
|
||||||
.finally(() => { if (!cancelled) setIsLoading(false); });
|
.finally(() => { if (!cancelled) setIsLoading(false); });
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
|
|||||||
@@ -2,21 +2,21 @@ import * as React from 'react';
|
|||||||
import { SharePointListsClient } from '../services/sharePointLists';
|
import { SharePointListsClient } from '../services/sharePointLists';
|
||||||
|
|
||||||
export interface SharePointSiteTitleState {
|
export interface SharePointSiteTitleState {
|
||||||
title: string | null;
|
title: string | undefined;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Заголовок сайта SharePoint по его url — для отображения «Название — url» вместо голого url. */
|
/** Заголовок сайта SharePoint по его url — для отображения «Название — url» вместо голого url. */
|
||||||
export function useSharePointSiteTitle(
|
export function useSharePointSiteTitle(
|
||||||
client: SharePointListsClient | undefined,
|
client: SharePointListsClient | undefined,
|
||||||
siteUrl: string | undefined | null
|
siteUrl: string | undefined
|
||||||
): SharePointSiteTitleState {
|
): SharePointSiteTitleState {
|
||||||
const [title, setTitle] = React.useState<string | null>(null);
|
const [title, setTitle] = React.useState<string | undefined>(undefined);
|
||||||
const [isLoading, setIsLoading] = React.useState(false);
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!client || !siteUrl?.trim()) {
|
if (!client || !siteUrl?.trim()) {
|
||||||
setTitle(null);
|
setTitle(undefined);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@ export function useSharePointSiteTitle(
|
|||||||
|
|
||||||
client.getSiteTitle(siteUrl.trim())
|
client.getSiteTitle(siteUrl.trim())
|
||||||
.then(result => { if (!cancelled) setTitle(result); })
|
.then(result => { if (!cancelled) setTitle(result); })
|
||||||
.catch(() => { if (!cancelled) setTitle(null); })
|
.catch(() => { if (!cancelled) setTitle(undefined); })
|
||||||
.finally(() => { if (!cancelled) setIsLoading(false); });
|
.finally(() => { if (!cancelled) setIsLoading(false); });
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Справочники api.tnsu.kz (компании, отделы, проекты) в виде, удобном для UI.
|
// Справочники api.tnsu.kz (компании, отделы, проекты) в виде, удобном для UI.
|
||||||
// Приведение из «сырых» PascalCase-ответов портала — в services/portalDictionary.ts.
|
// Приведение из «сырых» PascalCase-ответов портала — в services/portalDictionary.ts.
|
||||||
|
/* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт портала api.tnsu.kz */
|
||||||
|
|
||||||
export interface Company {
|
export interface Company {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -28,4 +29,8 @@ export interface Project {
|
|||||||
export interface Employee {
|
export interface Employee {
|
||||||
fio: string;
|
fio: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
/** Должность из 1С (AD) — '' если не заполнена в источнике. Используется как
|
||||||
|
* подсказка при выборе сотрудника (см. ManageRequestsApproverModal), не для
|
||||||
|
* отображения в списке (сам справочник должностей не показывает). */
|
||||||
|
position: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ export * from './requestTypes';
|
|||||||
export * from './templateRoutes';
|
export * from './templateRoutes';
|
||||||
export * from './templateRouteSteps';
|
export * from './templateRouteSteps';
|
||||||
export * from './resolve';
|
export * from './resolve';
|
||||||
|
export * from './requestManage';
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { REQUEST_STATUS, isEditableRowStatus, isFullyClosedRequestStatus } from './requestManage';
|
||||||
|
|
||||||
|
describe('isEditableRowStatus', () => {
|
||||||
|
it('черновик/в очереди/на согласовании/на исполнении — редактируемые', () => {
|
||||||
|
expect(isEditableRowStatus(REQUEST_STATUS.draft)).toBe(true);
|
||||||
|
expect(isEditableRowStatus(REQUEST_STATUS.queued)).toBe(true);
|
||||||
|
expect(isEditableRowStatus(REQUEST_STATUS.onApproval)).toBe(true);
|
||||||
|
expect(isEditableRowStatus(REQUEST_STATUS.onExecution)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"Возвращена на доработку" — редактируемый статус (можно переназначить)', () => {
|
||||||
|
expect(isEditableRowStatus(REQUEST_STATUS.returned)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('согласовано/выполнено/отклонено — финальные решения, НЕ редактируемые', () => {
|
||||||
|
expect(isEditableRowStatus(REQUEST_STATUS.approved)).toBe(false);
|
||||||
|
expect(isEditableRowStatus(REQUEST_STATUS.executed)).toBe(false);
|
||||||
|
expect(isEditableRowStatus(REQUEST_STATUS.rejected)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('незнакомый статус считается нередактируемым (безопасный дефолт)', () => {
|
||||||
|
expect(isEditableRowStatus('какой-то новый статус')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isFullyClosedRequestStatus', () => {
|
||||||
|
it('true только для статуса "Закрыта"', () => {
|
||||||
|
expect(isFullyClosedRequestStatus(REQUEST_STATUS.closed)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('false для любого другого статуса заявки, включая "Выполнение"', () => {
|
||||||
|
expect(isFullyClosedRequestStatus(REQUEST_STATUS.execution)).toBe(false);
|
||||||
|
expect(isFullyClosedRequestStatus(REQUEST_STATUS.draft)).toBe(false);
|
||||||
|
expect(isFullyClosedRequestStatus('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Домен вкладки «Редактирование заявок»: живёт целиком в SharePoint-списках
|
||||||
|
// «<форма> — данные» / «<форма> — согласование», которыми управляет
|
||||||
|
// request-portal (см. request_type.list_id/site_url — ссылки именно на список
|
||||||
|
// данных; список согласования вычисляется по конвенции имени, см. approvalListTitle
|
||||||
|
// в requestManageService.ts). Это НЕ сущности Tnsu.ApprovalRoute.Api — совпадений
|
||||||
|
// с models/templateRoutes.ts нет, здесь другое хранилище.
|
||||||
|
//
|
||||||
|
// Значения статусов — Choice-колонки SharePoint, 1:1 с
|
||||||
|
// request-portal/src/webparts/requestPortal/shared/approval/models/statuses.ts
|
||||||
|
// (тот же самый список, то же самое хранилище — просто другой клиент).
|
||||||
|
export const REQUEST_STATUS = {
|
||||||
|
draft: 'Черновик',
|
||||||
|
onApproval: 'На согласовании',
|
||||||
|
queued: 'Ждёт очереди',
|
||||||
|
onExecution: 'На исполнении',
|
||||||
|
executed: 'Выполнено',
|
||||||
|
approved: 'Согласовано',
|
||||||
|
rejected: 'Отклонено',
|
||||||
|
returned: 'Возвращена на доработку',
|
||||||
|
execution: 'Выполнение',
|
||||||
|
closed: 'Закрыта'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const APPROVAL_PHASE = {
|
||||||
|
approval: 'Согласование',
|
||||||
|
execution: 'Исполнение'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Строка маршрута в одном из этих статусов ЕЩЁ НЕ решена согласующим/исполнителем
|
||||||
|
// ОКОНЧАТЕЛЬНО — её можно менять (переназначить человека, удалить, переставить,
|
||||||
|
// добавить новую рядом). «Возвращена на доработку» сюда включена нарочно: это не
|
||||||
|
// финальное решение (заявка вернётся на новый круг после доработки), поэтому
|
||||||
|
// согласующего на этом шаге всё ещё можно переназначить. approved/executed/
|
||||||
|
// rejected — финальные решения, трогать нельзя ни при каких условиях.
|
||||||
|
const EDITABLE_ROW_STATUSES: string[] = [
|
||||||
|
REQUEST_STATUS.draft,
|
||||||
|
REQUEST_STATUS.queued,
|
||||||
|
REQUEST_STATUS.onApproval,
|
||||||
|
REQUEST_STATUS.onExecution,
|
||||||
|
REQUEST_STATUS.returned
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isEditableRowStatus(status: string): boolean {
|
||||||
|
return EDITABLE_ROW_STATUSES.indexOf(status) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заявка в этом статусе прошла полностью (все фазы завершены) — маршрут можно
|
||||||
|
// только посмотреть, редактирование выключается целиком независимо от статусов
|
||||||
|
// отдельных строк.
|
||||||
|
export function isFullyClosedRequestStatus(status: string): boolean {
|
||||||
|
return status === REQUEST_STATUS.closed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManagedRequestSummary {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
created: string; // ISO
|
||||||
|
authorName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManagedRequestPage {
|
||||||
|
items: ManagedRequestSummary[];
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строка списка «… — согласование». approverId/approverEmail нужны и для
|
||||||
|
// отображения, и как исходное значение при открытии формы переназначения.
|
||||||
|
export interface ManagedApprovalRow {
|
||||||
|
id: number;
|
||||||
|
ticketId: number;
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- lookup-поле SharePoint, у ghost-строки (см. handleAddApprover) значения ещё нет
|
||||||
|
approverId: number | null;
|
||||||
|
approverName: string;
|
||||||
|
approverEmail: string;
|
||||||
|
position: string;
|
||||||
|
status: string;
|
||||||
|
date: string;
|
||||||
|
comment: string;
|
||||||
|
order: number;
|
||||||
|
allowReassign: boolean;
|
||||||
|
phase: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManagedRequestMatrix {
|
||||||
|
request: ManagedRequestSummary;
|
||||||
|
/** Абсолютный URL сайта, где лежат списки данных/согласования этого типа заявки. */
|
||||||
|
siteUrl: string;
|
||||||
|
/** Title списка «… — согласование» (вычислен из Title списка данных). */
|
||||||
|
approvalListTitle: string;
|
||||||
|
/** Обе фазы вместе, упорядочены по StepOrder. */
|
||||||
|
rows: ManagedApprovalRow[];
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
// DTO эндпоинта api/request-types (Application/RequestTypes/*.cs).
|
// DTO эндпоинта api/request-types (Application/RequestTypes/*.cs).
|
||||||
|
/* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт бэкенда */
|
||||||
|
|
||||||
export interface RequestType {
|
export interface RequestType {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// DTO эндпоинта api/template-routes/resolve
|
// DTO эндпоинта api/template-routes/resolve
|
||||||
// (Application/TemplateRoutes/ResolveTemplateRouteDto.cs).
|
// (Application/TemplateRoutes/ResolveTemplateRouteDto.cs).
|
||||||
|
/* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт бэкенда */
|
||||||
|
|
||||||
import { RouteStepStatus } from './enums';
|
import { RouteStepStatus } from './enums';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// DTO эндпоинта api/template-routes/{routeId}/steps
|
// DTO эндпоинта api/template-routes/{routeId}/steps
|
||||||
// (Application/TemplateRouteSteps/*.cs).
|
// (Application/TemplateRouteSteps/*.cs).
|
||||||
|
/* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт бэкенда (C# nullable-типы сериализуются в null, не в отсутствие поля) */
|
||||||
|
|
||||||
import { ComparisonOperator, HeadType, ProjectRole, RouteStepStatus } from './enums';
|
import { ComparisonOperator, HeadType, ProjectRole, RouteStepStatus } from './enums';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// DTO эндпоинта api/template-routes (Application/TemplateRoutes/*.cs).
|
// DTO эндпоинта api/template-routes (Application/TemplateRoutes/*.cs).
|
||||||
|
/* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт бэкенда */
|
||||||
|
|
||||||
export interface TemplateRoute {
|
export interface TemplateRoute {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { navigate, parseRoute } from './routing';
|
||||||
|
|
||||||
|
// buildQuery/navigate читают/пишут window.location через history API — сбрасываем
|
||||||
|
// адрес перед каждым тестом, чтобы тесты не зависели от порядка запуска.
|
||||||
|
beforeEach(() => {
|
||||||
|
window.history.replaceState({}, '', '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseRoute', () => {
|
||||||
|
it('без aatab — вкладка по умолчанию dashboard, всё остальное пусто', () => {
|
||||||
|
expect(parseRoute('')).toEqual({ tab: 'dashboard', routeId: undefined, resolve: undefined, manage: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('незнакомое значение aatab тоже откатывается на dashboard', () => {
|
||||||
|
expect(parseRoute('?aatab=bogus-tab').tab).toBe('dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('manageRequests с aamtype+aamreq — даёт заполненный manage', () => {
|
||||||
|
const route = parseRoute('?aatab=manageRequests&aamtype=5&aamreq=42');
|
||||||
|
expect(route.tab).toBe('manageRequests');
|
||||||
|
expect(route.manage).toEqual({ requestTypeId: '5', requestId: '42' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('manageRequests без aamreq — manage не собирается (id заявки обязателен)', () => {
|
||||||
|
const route = parseRoute('?aatab=manageRequests&aamtype=5');
|
||||||
|
expect(route.manage).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aaroute учитывается только при tab=routes', () => {
|
||||||
|
expect(parseRoute('?aatab=routes&aaroute=7').routeId).toBe(7);
|
||||||
|
expect(parseRoute('?aatab=manageRequests&aaroute=7').routeId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('нечисловой/отсутствующий aaroute — routeId не задан', () => {
|
||||||
|
expect(parseRoute('?aatab=routes&aaroute=not-a-number').routeId).toBeUndefined();
|
||||||
|
expect(parseRoute('?aatab=routes').routeId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolve.fieldValues парсит валидный JSON-массив', () => {
|
||||||
|
const fields = encodeURIComponent(JSON.stringify([{ fieldName: 'Amount', value: '100' }]));
|
||||||
|
const route = parseRoute(`?aatab=resolve&aartype=1&aafields=${fields}`);
|
||||||
|
expect(route.resolve?.fieldValues).toEqual([{ fieldName: 'Amount', value: '100' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolve.fieldValues на битом JSON не роняет парсинг маршрута — просто пустой массив', () => {
|
||||||
|
const route = parseRoute('?aatab=resolve&aartype=1&aafields=not-json');
|
||||||
|
expect(route.resolve?.fieldValues).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolve не собирается без requestTypeId, даже если tab=resolve', () => {
|
||||||
|
expect(parseRoute('?aatab=resolve').resolve).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('navigate', () => {
|
||||||
|
it('после navigate() параметры читаются обратно тем же parseRoute (round-trip)', () => {
|
||||||
|
navigate({ tab: 'manageRequests', manage: { requestTypeId: '3', requestId: '10' } });
|
||||||
|
const roundTripped = parseRoute(window.location.search);
|
||||||
|
expect(roundTripped.tab).toBe('manageRequests');
|
||||||
|
expect(roundTripped.manage).toEqual({ requestTypeId: '3', requestId: '10' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigate на dashboard полностью убирает параметры вкладки из адреса', () => {
|
||||||
|
navigate({ tab: 'manageRequests', manage: { requestTypeId: '3', requestId: '10' } });
|
||||||
|
navigate({ tab: 'dashboard' });
|
||||||
|
expect(window.location.search).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('дописывает новую запись в историю (pushState, а не replaceState)', () => {
|
||||||
|
const initialLength = window.history.length;
|
||||||
|
navigate({ tab: 'requestTypes' });
|
||||||
|
expect(window.history.length).toBe(initialLength + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { ScreenKey } from './components/app/types';
|
||||||
|
import { ResolveFieldValue, RouteStepStatus } from './models';
|
||||||
|
|
||||||
|
// Маршрутизация веб-части по параметрам URL: у каждой верхней вкладки свой
|
||||||
|
// адрес, а «Проверка маршрута» и «Редактирование заявок» — ещё и глубокие
|
||||||
|
// ссылки на конкретный подбор (маршрут+сотрудник / конкретная заявка), которые
|
||||||
|
// можно передать коллеге или обновить страницу без потери контекста.
|
||||||
|
//
|
||||||
|
// Префикс aa (approval admin) — тот же приём, что в request-portal
|
||||||
|
// (src/webparts/requestPortal/shared/routing.ts, префикс rp): SharePoint
|
||||||
|
// резервирует часть имён параметров (id/mode/type) под свою навигацию, поэтому
|
||||||
|
// используем собственные, заведомо не пересекающиеся имена.
|
||||||
|
//
|
||||||
|
// Навигация — history.pushState без перезагрузки страницы (SPA); useRoute()
|
||||||
|
// подписан и на popstate (кнопка «назад»), и на собственное событие (pushState
|
||||||
|
// само popstate не порождает).
|
||||||
|
|
||||||
|
export interface IResolveRouteParams {
|
||||||
|
requestTypeId: string;
|
||||||
|
userEmail: string;
|
||||||
|
projectId: string;
|
||||||
|
/** Название проекта — чтобы показать выбор до отдельного похода в справочник. */
|
||||||
|
projectLabel: string;
|
||||||
|
status: RouteStepStatus;
|
||||||
|
fieldValues: ResolveFieldValue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IManageRequestRouteParams {
|
||||||
|
requestTypeId: string;
|
||||||
|
requestId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAdminRoute {
|
||||||
|
tab: ScreenKey;
|
||||||
|
/** Id маршрута — только при tab='routes': сразу открывает шаги ЭТОГО маршрута,
|
||||||
|
* минуя список (см. AdminApp — по нему домаршрут дочитывается через api.templateRoutes.get). */
|
||||||
|
routeId?: number;
|
||||||
|
/** Задан только при tab='resolve' и непустом requestTypeId. */
|
||||||
|
resolve?: IResolveRouteParams;
|
||||||
|
/** Задан только при tab='manageRequests' и непустых requestTypeId+requestId. */
|
||||||
|
manage?: IManageRequestRouteParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROUTE_CHANGE_EVENT = 'aa-route-change';
|
||||||
|
|
||||||
|
// 'routeSteps' сюда намеренно не входит — это не отдельная вкладка URL, а
|
||||||
|
// состояние вкладки 'routes' при наличии aaroute (см. parseRoute).
|
||||||
|
const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'manageRequests'];
|
||||||
|
|
||||||
|
const PARAM_KEYS = [
|
||||||
|
'aatab', 'aaroute',
|
||||||
|
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields',
|
||||||
|
'aamtype', 'aamreq'
|
||||||
|
];
|
||||||
|
|
||||||
|
// raw — результат URLSearchParams.get() (DOM API, возвращает string | null).
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null
|
||||||
|
function parseTab(raw: string | null): ScreenKey {
|
||||||
|
return raw && (TABS as string[]).indexOf(raw) !== -1 ? (raw as ScreenKey) : 'dashboard';
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- raw — результат URLSearchParams.get() (DOM API)
|
||||||
|
function parseFieldValues(raw: string | null): ResolveFieldValue[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed
|
||||||
|
.filter((x): x is Record<string, unknown> => typeof x === 'object' && x !== null)
|
||||||
|
.map(x => ({ fieldName: String(x.fieldName || ''), value: String(x.value || '') }));
|
||||||
|
} catch {
|
||||||
|
// Битый/устаревший JSON в ссылке — не роняем экран, просто без предзаполненных условий.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Разобрать строку запроса (по умолчанию — из адреса страницы) в маршрут. */
|
||||||
|
export function parseRoute(search: string = window.location.search): IAdminRoute {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
const tab = parseTab(params.get('aatab'));
|
||||||
|
|
||||||
|
const routeIdRaw = tab === 'routes' ? params.get('aaroute') : null;
|
||||||
|
const routeId = routeIdRaw ? Number(routeIdRaw) : undefined;
|
||||||
|
|
||||||
|
const resolveTypeId = params.get('aartype');
|
||||||
|
const resolve: IResolveRouteParams | undefined =
|
||||||
|
tab === 'resolve' && resolveTypeId
|
||||||
|
? {
|
||||||
|
requestTypeId: resolveTypeId,
|
||||||
|
userEmail: params.get('aauser') || '',
|
||||||
|
projectId: params.get('aaproject') || '',
|
||||||
|
projectLabel: params.get('aaprojectname') || '',
|
||||||
|
status: (params.get('aastatus') as RouteStepStatus) || RouteStepStatus.Approval,
|
||||||
|
fieldValues: parseFieldValues(params.get('aafields'))
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const manageTypeId = params.get('aamtype');
|
||||||
|
const manageReqId = params.get('aamreq');
|
||||||
|
const manage: IManageRequestRouteParams | undefined =
|
||||||
|
tab === 'manageRequests' && manageTypeId && manageReqId
|
||||||
|
? { requestTypeId: manageTypeId, requestId: manageReqId }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
tab,
|
||||||
|
routeId: routeId && !isNaN(routeId) ? routeId : undefined,
|
||||||
|
resolve,
|
||||||
|
manage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Собрать query-строку маршрута, сохранив посторонние параметры страницы
|
||||||
|
// (SharePoint/Workbench могут держать в URL свои — их трогать нельзя).
|
||||||
|
function buildQuery(route: IAdminRoute): string {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
PARAM_KEYS.forEach(k => params.delete(k));
|
||||||
|
|
||||||
|
if (route.tab !== 'dashboard') params.set('aatab', route.tab);
|
||||||
|
|
||||||
|
if (route.tab === 'routes' && route.routeId) {
|
||||||
|
params.set('aaroute', String(route.routeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.tab === 'resolve' && route.resolve && route.resolve.requestTypeId) {
|
||||||
|
params.set('aartype', route.resolve.requestTypeId);
|
||||||
|
if (route.resolve.userEmail) params.set('aauser', route.resolve.userEmail);
|
||||||
|
if (route.resolve.projectId) params.set('aaproject', route.resolve.projectId);
|
||||||
|
if (route.resolve.projectLabel) params.set('aaprojectname', route.resolve.projectLabel);
|
||||||
|
if (route.resolve.status) params.set('aastatus', route.resolve.status);
|
||||||
|
if (route.resolve.fieldValues.length > 0) params.set('aafields', JSON.stringify(route.resolve.fieldValues));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.tab === 'manageRequests' && route.manage && route.manage.requestTypeId && route.manage.requestId) {
|
||||||
|
params.set('aamtype', route.manage.requestTypeId);
|
||||||
|
params.set('aamreq', route.manage.requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = params.toString();
|
||||||
|
return `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Перейти на другой маршрут без перезагрузки страницы (SPA) — новая запись в истории. */
|
||||||
|
export function navigate(route: IAdminRoute): void {
|
||||||
|
window.history.pushState({}, '', buildQuery(route));
|
||||||
|
window.dispatchEvent(new Event(ROUTE_CHANGE_EVENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить параметры ТЕКУЩЕГО маршрута без новой записи в истории — для форм
|
||||||
|
* (ResolveScreen/ManageRequestsScreen), которые синхронизируют URL на каждое
|
||||||
|
* изменение поля: pushState на каждый символ ввода превратил бы кнопку «назад»
|
||||||
|
* в полистывание истории набора текста вместо переключения вкладок.
|
||||||
|
*/
|
||||||
|
export function replaceRoute(route: IAdminRoute): void {
|
||||||
|
window.history.replaceState({}, '', buildQuery(route));
|
||||||
|
window.dispatchEvent(new Event(ROUTE_CHANGE_EVENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ссылка на маршрут — для href (открыть в новой вкладке, скопировать, поделиться). */
|
||||||
|
export function routeUrl(route: IAdminRoute): string {
|
||||||
|
return buildQuery(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текущий маршрут, реактивно обновляется по navigate() и кнопке «назад». */
|
||||||
|
export function useRoute(): IAdminRoute {
|
||||||
|
const [route, setRoute] = React.useState<IAdminRoute>(() => parseRoute());
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onChange = (): void => setRoute(parseRoute());
|
||||||
|
window.addEventListener('popstate', onChange);
|
||||||
|
window.addEventListener(ROUTE_CHANGE_EVENT, onChange);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('popstate', onChange);
|
||||||
|
window.removeEventListener(ROUTE_CHANGE_EVENT, onChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return route;
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ export class ApiClientError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
export type QueryParams = Record<string, string | number | boolean | undefined>;
|
||||||
|
|
||||||
export interface IHttpClient {
|
export interface IHttpClient {
|
||||||
get<T>(path: string, query?: QueryParams): Promise<T>;
|
get<T>(path: string, query?: QueryParams): Promise<T>;
|
||||||
@@ -34,7 +34,7 @@ function buildUrl(baseUrl: string, path: string, query?: QueryParams): string {
|
|||||||
if (query) {
|
if (query) {
|
||||||
Object.keys(query).forEach(key => {
|
Object.keys(query).forEach(key => {
|
||||||
const value = query[key];
|
const value = query[key];
|
||||||
if (value !== undefined && value !== null && value !== '') {
|
if (value !== undefined && value !== '') {
|
||||||
url.searchParams.set(key, String(value));
|
url.searchParams.set(key, String(value));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @rushstack/no-new-null -- поля "сырых" IPortal*-интерфейсов 1:1 зеркалят nullable JSON-контракт портала api.tnsu.kz */
|
||||||
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||||
import { Company, Department, Employee, Project } from '../models';
|
import { Company, Department, Employee, Project } from '../models';
|
||||||
import { ApiClientError } from './httpClient';
|
import { ApiClientError } from './httpClient';
|
||||||
@@ -51,6 +52,7 @@ export interface IProjectSearchResult {
|
|||||||
interface IPortalEmployee {
|
interface IPortalEmployee {
|
||||||
fio: string;
|
fio: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
position: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getJson<T>(client: AadHttpClient, baseUrl: string, path: string): Promise<T> {
|
async function getJson<T>(client: AadHttpClient, baseUrl: string, path: string): Promise<T> {
|
||||||
@@ -107,7 +109,15 @@ export class PortalDictionaryClient {
|
|||||||
|
|
||||||
const byEmail = new Map<string, Employee>();
|
const byEmail = new Map<string, Employee>();
|
||||||
(raw ?? []).forEach(x => {
|
(raw ?? []).forEach(x => {
|
||||||
if (x.email && !byEmail.has(x.email)) byEmail.set(x.email, { fio: x.fio, email: x.email });
|
if (!x.email) return;
|
||||||
|
const existing = byEmail.get(x.email);
|
||||||
|
if (!existing) {
|
||||||
|
byEmail.set(x.email, { fio: x.fio, email: x.email, position: x.position || '' });
|
||||||
|
} else if (!existing.position && x.position) {
|
||||||
|
// Один сотрудник — строка на каждый его отдел; должность могла найтись
|
||||||
|
// не в первой из них.
|
||||||
|
existing.position = x.position;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return Array.from(byEmail.values());
|
return Array.from(byEmail.values());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import { spfi, SPFI, SPFx } from '@pnp/sp';
|
||||||
|
import '@pnp/sp/webs';
|
||||||
|
import '@pnp/sp/lists';
|
||||||
|
import '@pnp/sp/items';
|
||||||
|
import '@pnp/sp/site-users/web';
|
||||||
|
import '@pnp/sp/search';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import {
|
||||||
|
APPROVAL_PHASE,
|
||||||
|
ManagedApprovalRow,
|
||||||
|
ManagedRequestMatrix,
|
||||||
|
ManagedRequestPage,
|
||||||
|
ManagedRequestSummary,
|
||||||
|
RequestType
|
||||||
|
} from '../models';
|
||||||
|
|
||||||
|
// Список данных «<Форма> — данные» → список согласования «<Форма> — согласование»
|
||||||
|
// (конвенция имён; см. request-portal/src/webparts/requestPortal/forms/*/provisioning.json
|
||||||
|
// и forms/*/approval/provisioning.json — этот сервис читает и пишет ровно те же
|
||||||
|
// списки SharePoint, только с другого клиента).
|
||||||
|
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
|
||||||
|
|
||||||
|
// Внутренние имена колонок списка согласования — 1:1 с FIELDS в
|
||||||
|
// request-portal/src/webparts/requestPortal/shared/approval/approvalList.service.ts.
|
||||||
|
// Это тот же самый список SharePoint, поэтому имена колонок должны совпадать
|
||||||
|
// буквально — здесь их не выбирают заново, а просто дублируют.
|
||||||
|
const FIELDS = {
|
||||||
|
approver: 'TansuSiteApprover',
|
||||||
|
status: 'RequestStatus',
|
||||||
|
ticket: 'Ticket',
|
||||||
|
date: 'Date',
|
||||||
|
comment: 'Comment',
|
||||||
|
order: 'StepOrder',
|
||||||
|
allowReassign: 'AllowReassignToOtherApprovers',
|
||||||
|
phase: 'Phase'
|
||||||
|
};
|
||||||
|
|
||||||
|
function siteOrigin(siteUrl: string): string {
|
||||||
|
const match = /^https?:\/\/[^/]+/.exec(siteUrl);
|
||||||
|
return match ? match[0] : siteUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listAbsUrl(siteUrl: string, listId: string): string {
|
||||||
|
return `${siteOrigin(siteUrl)}${listId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// KQL не переносит большинство спецсимволов в свободном терме — оставляем буквы
|
||||||
|
// (лат./кир.), цифры и пробелы. Та же логика, что в request-portal
|
||||||
|
// (features/myRequests/myRequests.service.ts/sanitizeForKql).
|
||||||
|
function sanitizeForKql(text: string): string {
|
||||||
|
return text.replace(/[^a-zA-Zа-яА-ЯёЁ0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function freeTextClause(query: string): string | undefined {
|
||||||
|
const q = sanitizeForKql(query);
|
||||||
|
if (!q) return undefined;
|
||||||
|
return `(${q.split(' ').map(w => `${w}*`).join(' AND ')})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateIso(iso: string): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return '';
|
||||||
|
const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`;
|
||||||
|
const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`;
|
||||||
|
return `${dd}.${mm}.${d.getFullYear()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IRawDataRow {
|
||||||
|
Id: number;
|
||||||
|
Title?: string;
|
||||||
|
Status?: string;
|
||||||
|
Created?: string;
|
||||||
|
Author?: { Title?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSummary(r: IRawDataRow): ManagedRequestSummary {
|
||||||
|
return {
|
||||||
|
id: r.Id,
|
||||||
|
title: r.Title || `#${r.Id}`,
|
||||||
|
status: r.Status || '',
|
||||||
|
created: r.Created || '',
|
||||||
|
authorName: r.Author?.Title || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Чтение/правка заявок и их маршрутов согласования НАПРЯМУЮ в SharePoint —
|
||||||
|
* в отличие от остального этого веб-парта (который говорит с Tnsu.ApprovalRoute.Api),
|
||||||
|
* данные заявок и строки маршрута живут только в списках SharePoint, которыми
|
||||||
|
* управляет request-portal. У бэкенда этого API нет и не будет: типы заявок
|
||||||
|
* знают только list_id/site_url (см. models/requestTypes.ts), содержимое списков
|
||||||
|
* ему не принадлежит.
|
||||||
|
*/
|
||||||
|
export class RequestManageService {
|
||||||
|
constructor(private readonly context: WebPartContext) {}
|
||||||
|
|
||||||
|
private clientFor(siteUrl: string): SPFI {
|
||||||
|
return spfi(siteUrl).using(SPFx(this.context));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title списка данных → Title списка согласования. undefined, если Title не
|
||||||
|
// оканчивается на «— данные» (список заведён не по конвенции request-portal,
|
||||||
|
// управлять маршрутом для него отсюда нельзя).
|
||||||
|
private async approvalListTitle(sp: SPFI, listId: string): Promise<string | undefined> {
|
||||||
|
const info = (await sp.web.getList(listId).select('Title')()) as { Title: string };
|
||||||
|
if (!DATA_LIST_SUFFIX.test(info.Title)) return undefined;
|
||||||
|
return `${info.Title.replace(DATA_LIST_SUFFIX, '')} — согласование`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Заявки типа requestType постранично, для комбобокса выбора заявки. Сначала
|
||||||
|
* Search по названию (свободный терм, пустой query — просто последние без
|
||||||
|
* фильтра), затем дочитывание полей REST'ом по найденным Id — Search не
|
||||||
|
* отдаёт произвольные поля списка надёжно (та же двухшаговая схема, что в
|
||||||
|
* request-portal).
|
||||||
|
*/
|
||||||
|
public async searchRequests(
|
||||||
|
requestType: RequestType,
|
||||||
|
query: string,
|
||||||
|
page: number,
|
||||||
|
pageSize = 20
|
||||||
|
): Promise<ManagedRequestPage> {
|
||||||
|
if (!requestType.listId || !requestType.siteUrl) return { items: [], hasMore: false };
|
||||||
|
|
||||||
|
const sp = this.clientFor(requestType.siteUrl);
|
||||||
|
const absUrl = listAbsUrl(requestType.siteUrl, requestType.listId);
|
||||||
|
const clauses = [`Path:"${absUrl}*"`];
|
||||||
|
const text = freeTextClause(query);
|
||||||
|
if (text) clauses.push(text);
|
||||||
|
|
||||||
|
const result = await sp.search({
|
||||||
|
Querytext: clauses.join(' AND '),
|
||||||
|
SelectProperties: ['ListItemID', 'Path'],
|
||||||
|
RowLimit: pageSize,
|
||||||
|
StartRow: (page - 1) * pageSize,
|
||||||
|
TrimDuplicates: false,
|
||||||
|
SortList: [{ Property: 'Created', Direction: 1 }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawRows = (result.PrimarySearchResults ?? []) as Array<{ ListItemID?: string }>;
|
||||||
|
const ids = rawRows.map(r => Number(r.ListItemID)).filter(id => !isNaN(id) && id > 0);
|
||||||
|
if (ids.length === 0) return { items: [], hasMore: false };
|
||||||
|
|
||||||
|
const list = sp.web.getList(requestType.listId);
|
||||||
|
const dataRows = (await list.items
|
||||||
|
.filter(ids.map(id => `Id eq ${id}`).join(' or '))
|
||||||
|
.select('Id', 'Title', 'Status', 'Created', 'Author/Title')
|
||||||
|
.expand('Author')
|
||||||
|
.top(ids.length)()) as IRawDataRow[];
|
||||||
|
|
||||||
|
const byId = new Map(dataRows.map(r => [r.Id, r]));
|
||||||
|
// Порядок задаёт Search (SortList по дате), не порядок ответа REST.
|
||||||
|
const items = ids
|
||||||
|
.map(id => byId.get(id))
|
||||||
|
.filter((r): r is IRawDataRow => !!r)
|
||||||
|
.map(toSummary);
|
||||||
|
|
||||||
|
const totalRows = (result as unknown as { TotalRows?: number }).TotalRows ?? items.length;
|
||||||
|
return { items, hasMore: page * pageSize < totalRows };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Заявка + полный маршрут (обе фазы, по StepOrder) — для экрана редактирования. */
|
||||||
|
public async getMatrix(requestType: RequestType, requestId: number): Promise<ManagedRequestMatrix> {
|
||||||
|
if (!requestType.listId || !requestType.siteUrl) {
|
||||||
|
throw new Error('У типа заявки не указан список SharePoint.');
|
||||||
|
}
|
||||||
|
const sp = this.clientFor(requestType.siteUrl);
|
||||||
|
const dataList = sp.web.getList(requestType.listId);
|
||||||
|
|
||||||
|
const [requestRow, approvalListTitle] = await Promise.all([
|
||||||
|
dataList.items
|
||||||
|
.getById(requestId)
|
||||||
|
.select('Id', 'Title', 'Status', 'Created', 'Author/Title')
|
||||||
|
.expand('Author')() as Promise<IRawDataRow>,
|
||||||
|
this.approvalListTitle(sp, requestType.listId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!approvalListTitle) {
|
||||||
|
return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, approvalListTitle: '', rows: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
|
||||||
|
const rawRows = (await approvalList.items
|
||||||
|
.filter(`${FIELDS.ticket}Id eq ${requestId}`)
|
||||||
|
.select(
|
||||||
|
'Id',
|
||||||
|
'Title',
|
||||||
|
FIELDS.status,
|
||||||
|
FIELDS.date,
|
||||||
|
FIELDS.comment,
|
||||||
|
FIELDS.order,
|
||||||
|
FIELDS.allowReassign,
|
||||||
|
FIELDS.phase,
|
||||||
|
`${FIELDS.approver}/Id`,
|
||||||
|
`${FIELDS.approver}/Title`,
|
||||||
|
`${FIELDS.approver}/EMail`
|
||||||
|
)
|
||||||
|
.expand(FIELDS.approver)
|
||||||
|
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
const rows: ManagedApprovalRow[] = rawRows.map((r, i) => {
|
||||||
|
const approver = r[FIELDS.approver] as { Id?: number; Title?: string; EMail?: string } | undefined;
|
||||||
|
return {
|
||||||
|
id: r.Id as number,
|
||||||
|
ticketId: requestId,
|
||||||
|
approverId: approver?.Id ?? null,
|
||||||
|
approverName: approver?.Title || '',
|
||||||
|
approverEmail: approver?.EMail || '',
|
||||||
|
position: (r.Title as string) || '',
|
||||||
|
status: (r[FIELDS.status] as string) || '',
|
||||||
|
date: formatDateIso((r[FIELDS.date] as string) || ''),
|
||||||
|
comment: (r[FIELDS.comment] as string) || '',
|
||||||
|
order: (r[FIELDS.order] as number) ?? i + 1,
|
||||||
|
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
|
||||||
|
phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, approvalListTitle, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** email → числовой Id пользователя сайта (Person-поле требует Id, не email). */
|
||||||
|
private async resolveUserId(sp: SPFI, email: string): Promise<number> {
|
||||||
|
const login = email.indexOf('|') >= 0 ? email : `i:0#.f|membership|${email}`;
|
||||||
|
const user = await sp.web.ensureUser(login);
|
||||||
|
return user.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Переназначить согласующего/исполнителя строки. Вызывающая сторона обязана
|
||||||
|
* проверить isEditableRowStatus(row.status) — сервис это не перепроверяет. */
|
||||||
|
public async reassignApprover(
|
||||||
|
matrix: Pick<ManagedRequestMatrix, 'siteUrl' | 'approvalListTitle'>,
|
||||||
|
rowId: number,
|
||||||
|
email: string,
|
||||||
|
position: string
|
||||||
|
): Promise<void> {
|
||||||
|
const sp = this.clientFor(matrix.siteUrl);
|
||||||
|
const approverId = await this.resolveUserId(sp, email);
|
||||||
|
await sp.web.lists.getByTitle(matrix.approvalListTitle).items.getById(rowId).update({
|
||||||
|
Title: position,
|
||||||
|
[`${FIELDS.approver}Id`]: approverId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Удалить строку маршрута. */
|
||||||
|
public async removeRow(
|
||||||
|
matrix: Pick<ManagedRequestMatrix, 'siteUrl' | 'approvalListTitle'>,
|
||||||
|
rowId: number
|
||||||
|
): Promise<void> {
|
||||||
|
const sp = this.clientFor(matrix.siteUrl);
|
||||||
|
await sp.web.lists.getByTitle(matrix.approvalListTitle).items.getById(rowId).delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавить новую строку в фазу заявки. order считает вызывающая сторона
|
||||||
|
* (см. ManageRequestsScreen) — он обязан быть больше order любой уже решённой
|
||||||
|
* строки той же фазы (иначе новый согласующий встанет впереди того, кто уже
|
||||||
|
* согласовал, а это правило запрещено интерфейсом).
|
||||||
|
*/
|
||||||
|
public async addRow(
|
||||||
|
matrix: Pick<ManagedRequestMatrix, 'siteUrl' | 'approvalListTitle'>,
|
||||||
|
ticketId: number,
|
||||||
|
phase: string,
|
||||||
|
order: number,
|
||||||
|
status: string,
|
||||||
|
email: string,
|
||||||
|
position: string,
|
||||||
|
allowReassign: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
const sp = this.clientFor(matrix.siteUrl);
|
||||||
|
const approverId = await this.resolveUserId(sp, email);
|
||||||
|
await sp.web.lists.getByTitle(matrix.approvalListTitle).items.add({
|
||||||
|
Title: position || email,
|
||||||
|
[`${FIELDS.approver}Id`]: approverId,
|
||||||
|
[FIELDS.status]: status,
|
||||||
|
[`${FIELDS.ticket}Id`]: ticketId,
|
||||||
|
[FIELDS.order]: order,
|
||||||
|
[FIELDS.allowReassign]: allowReassign,
|
||||||
|
[FIELDS.phase]: phase
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переставить StepOrder нескольких строк разом (перестановка шагов местами) и,
|
||||||
|
* при необходимости, статус той же строки — см. ManageRequestsScreen/
|
||||||
|
* computeActivationUpdates: после любой перестановки строка, ставшая первой
|
||||||
|
* нерешённой в очереди фазы, должна получить «На согласовании»/«На
|
||||||
|
* исполнении» вместо «Ждёт очереди» (и наоборот — та, что раньше была первой,
|
||||||
|
* а теперь нет). Оба поля пишутся одним update, чтобы не плодить лишний
|
||||||
|
* запрос на строку.
|
||||||
|
*/
|
||||||
|
public async reorderRows(
|
||||||
|
matrix: Pick<ManagedRequestMatrix, 'siteUrl' | 'approvalListTitle'>,
|
||||||
|
updates: Array<{ id: number; order?: number; status?: string }>
|
||||||
|
): Promise<void> {
|
||||||
|
const sp = this.clientFor(matrix.siteUrl);
|
||||||
|
const list = sp.web.lists.getByTitle(matrix.approvalListTitle);
|
||||||
|
await Promise.all(
|
||||||
|
updates.map(u => {
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
if (u.order !== undefined) patch[FIELDS.order] = u.order;
|
||||||
|
if (u.status !== undefined) patch[FIELDS.status] = u.status;
|
||||||
|
return list.items.getById(u.id).update(patch);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ export function isNumericFieldType(typeAsString: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** «Название — url», либо просто url, пока название ещё не подтянулось/не нашлось. */
|
/** «Название — url», либо просто url, пока название ещё не подтянулось/не нашлось. */
|
||||||
export function formatSiteLabel(title: string | null | undefined, url: string): string {
|
export function formatSiteLabel(title: string | undefined, url: string): string {
|
||||||
return title ? `${title} — ${url}` : url;
|
return title ? `${title} — ${url}` : url;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,8 +79,8 @@ export class SharePointListsClient {
|
|||||||
// Заголовок сайта по site_url и заголовок списка по (site_url, list_id) бэкенд
|
// Заголовок сайта по site_url и заголовок списка по (site_url, list_id) бэкенд
|
||||||
// не хранит (только сами url) — кэшируем на клиенте, чтобы не дёргать сайт
|
// не хранит (только сами url) — кэшируем на клиенте, чтобы не дёргать сайт
|
||||||
// заново на каждый рендер строки таблицы или переоткрытие формы с тем же значением.
|
// заново на каждый рендер строки таблицы или переоткрытие формы с тем же значением.
|
||||||
private readonly siteTitleCache = new Map<string, string | null>();
|
private readonly siteTitleCache = new Map<string, string | undefined>();
|
||||||
private readonly listTitleCache = new Map<string, string | null>();
|
private readonly listTitleCache = new Map<string, string | undefined>();
|
||||||
|
|
||||||
constructor(private readonly context: WebPartContext) {}
|
constructor(private readonly context: WebPartContext) {}
|
||||||
|
|
||||||
@@ -90,33 +90,31 @@ export class SharePointListsClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Заголовок сайта по его url — для отображения рядом с url там, где хранится только он. */
|
/** Заголовок сайта по его url — для отображения рядом с url там, где хранится только он. */
|
||||||
public async getSiteTitle(siteUrl: string): Promise<string | null> {
|
public async getSiteTitle(siteUrl: string): Promise<string | undefined> {
|
||||||
const cached = this.siteTitleCache.get(siteUrl);
|
if (this.siteTitleCache.has(siteUrl)) return this.siteTitleCache.get(siteUrl);
|
||||||
if (cached !== undefined) return cached;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const web = await spfi(siteUrl).using(SPFx(this.context)).web.select('Title')() as { Title: string };
|
const web = await spfi(siteUrl).using(SPFx(this.context)).web.select('Title')() as { Title: string };
|
||||||
this.siteTitleCache.set(siteUrl, web.Title);
|
this.siteTitleCache.set(siteUrl, web.Title);
|
||||||
return web.Title;
|
return web.Title;
|
||||||
} catch {
|
} catch {
|
||||||
this.siteTitleCache.set(siteUrl, null);
|
this.siteTitleCache.set(siteUrl, undefined);
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Заголовок списка по (site_url, list_id — server-relative url списка) — для отображения там, где хранится только он. */
|
/** Заголовок списка по (site_url, list_id — server-relative url списка) — для отображения там, где хранится только он. */
|
||||||
public async getListTitle(siteUrl: string, listId: string): Promise<string | null> {
|
public async getListTitle(siteUrl: string, listId: string): Promise<string | undefined> {
|
||||||
const key = `${siteUrl}|${listId}`;
|
const key = `${siteUrl}|${listId}`;
|
||||||
const cached = this.listTitleCache.get(key);
|
if (this.listTitleCache.has(key)) return this.listTitleCache.get(key);
|
||||||
if (cached !== undefined) return cached;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const list = await clientFor(this.context, siteUrl).web.getList(listId).select('Title')() as { Title: string };
|
const list = await clientFor(this.context, siteUrl).web.getList(listId).select('Title')() as { Title: string };
|
||||||
this.listTitleCache.set(key, list.Title);
|
this.listTitleCache.set(key, list.Title);
|
||||||
return list.Title;
|
return list.Title;
|
||||||
} catch {
|
} catch {
|
||||||
this.listTitleCache.set(key, null);
|
this.listTitleCache.set(key, undefined);
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ export const texts = {
|
|||||||
dashboard: 'Дашборд',
|
dashboard: 'Дашборд',
|
||||||
requestTypes: 'Типы заявок',
|
requestTypes: 'Типы заявок',
|
||||||
routes: 'Маршруты',
|
routes: 'Маршруты',
|
||||||
resolve: 'Проверка маршрута'
|
resolve: 'Проверка маршрута',
|
||||||
|
manageRequests: 'Редактирование заявок'
|
||||||
},
|
},
|
||||||
|
|
||||||
app: {
|
app: {
|
||||||
@@ -269,6 +270,53 @@ export const texts = {
|
|||||||
emptySteps: 'Маршрут разрешился без шагов для этого статуса.'
|
emptySteps: 'Маршрут разрешился без шагов для этого статуса.'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
manageRequests: {
|
||||||
|
breadcrumb: 'Редактирование заявок',
|
||||||
|
subtitle: 'Правка маршрута согласования',
|
||||||
|
title: 'Редактирование заявок',
|
||||||
|
requestTypeLabel: 'Тип заявки',
|
||||||
|
requestTypePlaceholder: 'Выберите тип заявки',
|
||||||
|
requestTypeEmpty: 'Нет типов заявок с привязанным списком SharePoint.',
|
||||||
|
requestLabel: 'Заявка',
|
||||||
|
requestPlaceholder: 'Выберите заявку',
|
||||||
|
requestSearchPlaceholder: 'Поиск по названию...',
|
||||||
|
requestDisabledHint: 'Сначала выберите тип заявки.',
|
||||||
|
noApprovalList: 'У этого типа заявки список согласования не найден (Title списка данных не оканчивается на «— данные»).',
|
||||||
|
requestStatusLabel: 'Статус заявки',
|
||||||
|
fullyClosedNotice: 'Заявка полностью закрыта — маршрут доступен только для просмотра.',
|
||||||
|
approvalPhaseTitle: 'Согласование',
|
||||||
|
executionPhaseTitle: 'Исполнение',
|
||||||
|
columnOrder: 'Шаг',
|
||||||
|
columnApprover: 'Согласующий',
|
||||||
|
columnPosition: 'Должность',
|
||||||
|
columnStatus: 'Статус',
|
||||||
|
columnDate: 'Дата',
|
||||||
|
emptyPhase: 'Шагов нет.',
|
||||||
|
emptyMatrix: 'У этой заявки маршрут ещё не записан.',
|
||||||
|
reassign: 'Переназначить',
|
||||||
|
remove: 'Удалить шаг',
|
||||||
|
addApproverPlaceholder: 'Добавить согласующего: начните вводить ФИО…',
|
||||||
|
addExecutorPlaceholder: 'Добавить исполнителя: начните вводить ФИО…',
|
||||||
|
lockedHint: 'Решение уже принято — недоступно для правки.',
|
||||||
|
ghostHint: 'Сохраняется…',
|
||||||
|
phaseClosedHint: 'Эта фаза уже завершена — добавить в неё новый шаг нельзя.',
|
||||||
|
moveUp: 'Шаг выше',
|
||||||
|
moveDown: 'Шаг ниже',
|
||||||
|
mergeWithPrev: 'Объединить с предыдущим шагом (выполнять параллельно)',
|
||||||
|
splitFromPrev: 'Отделить от предыдущего шага (выполнять после него)',
|
||||||
|
moveBlockedByDecision: 'Нельзя переставить — соседний шаг уже решён.',
|
||||||
|
reassignTitle: 'Переназначить шаг',
|
||||||
|
approverLabel: 'Сотрудник',
|
||||||
|
approverPlaceholder: 'Выберите сотрудника',
|
||||||
|
validationApprover: 'Выберите сотрудника.',
|
||||||
|
deleteTitle: 'Удалить шаг?',
|
||||||
|
deleteText: (position: string): string => `Шаг «${position || 'без должности'}» будет удалён безвозвратно.`,
|
||||||
|
orderFloorHint: (order: number): string =>
|
||||||
|
`Шаг встанет на позицию ${order} — раньше уже принятых решений в этой фазе поставить нельзя.`,
|
||||||
|
loadError: 'Не удалось загрузить заявку',
|
||||||
|
saveError: 'Не удалось сохранить'
|
||||||
|
},
|
||||||
|
|
||||||
// Значения по умолчанию для переиспользуемых контролов выбора.
|
// Значения по умолчанию для переиспользуемых контролов выбора.
|
||||||
select: {
|
select: {
|
||||||
placeholder: 'Выберите значение',
|
placeholder: 'Выберите значение',
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Employee } from '../models';
|
||||||
|
import { findEmployeeByEmail, toEmployeeOption } from './employee';
|
||||||
|
|
||||||
|
const employees: Employee[] = [
|
||||||
|
{ fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик middle' },
|
||||||
|
{ fio: 'Соколов Давид', email: 'sokolov.david@tnsu.kz', position: 'Руководитель отдела' }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('findEmployeeByEmail', () => {
|
||||||
|
it('находит сотрудника по точному совпадению email', () => {
|
||||||
|
expect(findEmployeeByEmail(employees, 'gorbenko.pavel@tnsu.kz')?.fio).toBe('Горбенко Павел');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('сравнение регистронезависимое', () => {
|
||||||
|
expect(findEmployeeByEmail(employees, 'GORBENKO.PAVEL@TNSU.KZ')?.fio).toBe('Горбенко Павел');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('возвращает undefined, если email не задан', () => {
|
||||||
|
expect(findEmployeeByEmail(employees, undefined)).toBeUndefined();
|
||||||
|
expect(findEmployeeByEmail(employees, '')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('возвращает undefined, если сотрудник с таким email не найден', () => {
|
||||||
|
expect(findEmployeeByEmail(employees, 'nobody@tnsu.kz')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toEmployeeOption', () => {
|
||||||
|
it('строит опцию для SearchableSelect из сотрудника', () => {
|
||||||
|
expect(toEmployeeOption(employees[0])).toEqual({
|
||||||
|
value: 'gorbenko.pavel@tnsu.kz',
|
||||||
|
label: 'Горбенко Павел',
|
||||||
|
sublabel: 'gorbenko.pavel@tnsu.kz'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@ export function toEmployeeOption(employee: Employee): ISearchableOption {
|
|||||||
return { value: employee.email, label: employee.fio, sublabel: employee.email };
|
return { value: employee.email, label: employee.fio, sublabel: employee.email };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findEmployeeByEmail(employees: Employee[], email: string | null): Employee | undefined {
|
export function findEmployeeByEmail(employees: Employee[], email: string | undefined): Employee | undefined {
|
||||||
if (!email) return undefined;
|
if (!email) return undefined;
|
||||||
const target = email.toLowerCase();
|
const target = email.toLowerCase();
|
||||||
return employees.find(e => e.email.toLowerCase() === target);
|
return employees.find(e => e.email.toLowerCase() === target);
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describeError, reportError } from './errors';
|
||||||
|
|
||||||
|
describe('describeError', () => {
|
||||||
|
it('вытаскивает читаемый текст из сырого odata.error-ответа SharePoint', () => {
|
||||||
|
const raw =
|
||||||
|
'Error making HttpClient request in queryable [500] :: ' +
|
||||||
|
'{"odata.error":{"code":"-1, Microsoft.SharePoint.SPException","message":{"lang":"ru-RU","value":"Не удалось сохранить."}}}';
|
||||||
|
expect(describeError(new Error(raw))).toBe('Не удалось сохранить.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('раскрывает \\uXXXX-escape внутри value в кириллицу (JSON.parse делает это сам)', () => {
|
||||||
|
const raw = '{"odata.error":{"message":{"lang":"ru-RU","value":"\\u041f\\u0440\\u0438\\u0432\\u0435\\u0442"}}}';
|
||||||
|
expect(describeError(new Error(raw))).toBe('Привет');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('возвращает исходный текст, если в сообщении нет JSON', () => {
|
||||||
|
expect(describeError(new Error('Просто текст ошибки'))).toBe('Просто текст ошибки');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('возвращает исходный текст, если JSON есть, но не парсится (обрезан)', () => {
|
||||||
|
const raw = 'Error :: {"odata.error":{"message":';
|
||||||
|
expect(describeError(new Error(raw))).toBe(raw);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('возвращает исходный текст, если JSON валиден, но без odata.error.message.value', () => {
|
||||||
|
const raw = '{"foo":"bar"}';
|
||||||
|
expect(describeError(new Error(raw))).toBe(raw);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('работает не только с Error, но и с произвольным выброшенным значением', () => {
|
||||||
|
expect(describeError('строка вместо Error')).toBe('строка вместо Error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reportError', () => {
|
||||||
|
it('логирует ошибку целиком в консоль и возвращает читаемый текст', () => {
|
||||||
|
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const error = new Error('{"odata.error":{"message":{"value":"Сбой"}}}');
|
||||||
|
|
||||||
|
const result = reportError(error, 'someContext');
|
||||||
|
|
||||||
|
expect(result).toBe('Сбой');
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(spy.mock.calls[0][0]).toContain('someContext');
|
||||||
|
expect(spy.mock.calls[0][1]).toBe(error);
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// PnPjs (getById(...).update(...) и т.п.) при ошибке SharePoint кладёт в
|
||||||
|
// Error.message не только код HTTP, но и ВЕСЬ сырой текст ответа сервера —
|
||||||
|
// это JSON вида {"odata.error":{"code":"...","message":{"lang":"ru-RU","value":"..."}}}.
|
||||||
|
// Если просто вывести e.message пользователю, он увидит фигурные скобки,
|
||||||
|
// "odata.error" и escape-последовательности \uXXXX прямо как текст (это НЕ
|
||||||
|
// баг кодировки — JSON.parse их корректно раскрывает в кириллицу, но e.message
|
||||||
|
// это просто СТРОКА, а не распарсенный объект, вот они и остаются как есть).
|
||||||
|
|
||||||
|
interface ISharePointErrorEnvelope {
|
||||||
|
'odata.error'?: { message?: { value?: string } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Читаемый текст ошибки — вытаскивает odata.error.message.value, если он есть. */
|
||||||
|
export function describeError(e: unknown): string {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
const jsonStart = message.indexOf('{');
|
||||||
|
if (jsonStart === -1) return message;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(message.slice(jsonStart)) as ISharePointErrorEnvelope;
|
||||||
|
return parsed['odata.error']?.message?.value || message;
|
||||||
|
} catch {
|
||||||
|
// Не JSON (или обрезан) — отдаём как есть, лучше сырой текст, чем ничего.
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Полная ошибка — в консоль (для диагностики), пользователю возвращается только читаемый текст. */
|
||||||
|
export function reportError(e: unknown, context: string): string {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`[ApprovalAdmin] ${context}`, e);
|
||||||
|
return describeError(e);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { IOrderedItem, blockIndexOf, blockRanges, markParallel, swapBlocks, withOrdersFrom } from './orderBlocks';
|
||||||
|
|
||||||
|
interface TestRow extends IOrderedItem {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(id: number, order: number, parallelWithPrev?: boolean): TestRow {
|
||||||
|
return { id, order, parallelWithPrev };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('blockRanges', () => {
|
||||||
|
it('кладёт каждую непараллельную строку в отдельный блок', () => {
|
||||||
|
const rows = [row(1, 1), row(2, 2), row(3, 3)];
|
||||||
|
expect(blockRanges(rows)).toEqual([
|
||||||
|
{ start: 0, end: 0 },
|
||||||
|
{ start: 1, end: 1 },
|
||||||
|
{ start: 2, end: 2 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('объединяет подряд идущие parallelWithPrev-строки в один блок', () => {
|
||||||
|
const rows = [row(1, 1), row(2, 2), row(3, 2, true), row(4, 3)];
|
||||||
|
expect(blockRanges(rows)).toEqual([
|
||||||
|
{ start: 0, end: 0 },
|
||||||
|
{ start: 1, end: 2 },
|
||||||
|
{ start: 3, end: 3 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('игнорирует parallelWithPrev у самой первой строки — блок ей всё равно свой', () => {
|
||||||
|
const rows = [row(1, 1, true)];
|
||||||
|
expect(blockRanges(rows)).toEqual([{ start: 0, end: 0 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('для пустого списка строк возвращает пустой список блоков', () => {
|
||||||
|
expect(blockRanges([])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('blockIndexOf', () => {
|
||||||
|
const ranges = [
|
||||||
|
{ start: 0, end: 0 },
|
||||||
|
{ start: 1, end: 2 },
|
||||||
|
{ start: 3, end: 3 }
|
||||||
|
];
|
||||||
|
|
||||||
|
it('находит блок для строки в его середине/на границах', () => {
|
||||||
|
expect(blockIndexOf(ranges, 0)).toBe(0);
|
||||||
|
expect(blockIndexOf(ranges, 1)).toBe(1);
|
||||||
|
expect(blockIndexOf(ranges, 2)).toBe(1);
|
||||||
|
expect(blockIndexOf(ranges, 3)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('возвращает -1, если индекс строки ни в один диапазон не попадает', () => {
|
||||||
|
expect(blockIndexOf(ranges, 99)).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('swapBlocks', () => {
|
||||||
|
it('меняет местами два блока, не трогая порядок строк внутри них', () => {
|
||||||
|
const rows = ['a', 'b1', 'b2', 'c'];
|
||||||
|
const a = { start: 0, end: 0 };
|
||||||
|
const b = { start: 1, end: 2 };
|
||||||
|
expect(swapBlocks(rows, a, b)).toEqual(['b1', 'b2', 'a', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не трогает строки за пределами переставляемых блоков', () => {
|
||||||
|
const rows = ['head', 'a', 'b', 'tail'];
|
||||||
|
const a = { start: 1, end: 1 };
|
||||||
|
const b = { start: 2, end: 2 };
|
||||||
|
expect(swapBlocks(rows, a, b)).toEqual(['head', 'b', 'a', 'tail']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('withOrdersFrom', () => {
|
||||||
|
it('нумерует непараллельные строки последовательно начиная с floor+1', () => {
|
||||||
|
const rows = [row(1, 5), row(2, 5), row(3, 5)];
|
||||||
|
const result = withOrdersFrom(rows, 0);
|
||||||
|
expect(result.map(r => r.order)).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('строкам с parallelWithPrev даёт тот же order, что и предыдущей строке', () => {
|
||||||
|
const rows = [row(1, 1), row(2, 1, true), row(3, 1)];
|
||||||
|
const result = withOrdersFrom(rows, 0);
|
||||||
|
expect(result.map(r => r.order)).toEqual([1, 1, 2]);
|
||||||
|
expect(result.map(r => r.parallelWithPrev)).toEqual([false, true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('первая строка никогда не параллельна, даже если parallelWithPrev выставлен явно', () => {
|
||||||
|
const rows = [row(1, 1, true), row(2, 1)];
|
||||||
|
const result = withOrdersFrom(rows, 0);
|
||||||
|
expect(result[0].parallelWithPrev).toBe(false);
|
||||||
|
expect(result[0].order).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('floor сдвигает нумерацию — строки после заблокированного шага не могут получить order <= floor', () => {
|
||||||
|
const rows = [row(1, 1), row(2, 2)];
|
||||||
|
const result = withOrdersFrom(rows, 3);
|
||||||
|
expect(result.map(r => r.order)).toEqual([4, 5]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markParallel', () => {
|
||||||
|
it('помечает строку как параллельную, только если её order совпадает с order предыдущей', () => {
|
||||||
|
const rows = [row(1, 1), row(2, 1), row(3, 2), row(4, 2)];
|
||||||
|
const result = markParallel(rows);
|
||||||
|
expect(result.map(r => r.parallelWithPrev)).toEqual([false, true, false, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('первая строка списка никогда не параллельна', () => {
|
||||||
|
const rows = [row(1, 1)];
|
||||||
|
expect(markParallel(rows)[0].parallelWithPrev).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Общая механика «шагов, которые могут идти параллельно» — 1:1 повторяет
|
||||||
|
// request-portal/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts
|
||||||
|
// (blockRanges/swapBlocks/withOrders/markParallel), только обобщена под
|
||||||
|
// generic-тип с полями order/parallelWithPrev, чтобы работать и с
|
||||||
|
// ManagedApprovalRow здесь, и не тащить зависимость на request-portal.
|
||||||
|
//
|
||||||
|
// Параллельность кодируется ЧЕРЕЗ РАВНЫЙ order у соседних строк — отдельного
|
||||||
|
// флага в SharePoint нет (см. FIELDS в requestManageService.ts: там только
|
||||||
|
// StepOrder). parallelWithPrev — это производное поле, вычисляемое на клиенте
|
||||||
|
// из совпадения order с предыдущей строкой (см. markParallel), а не то, что
|
||||||
|
// хранится в списке.
|
||||||
|
export interface IOrderedItem {
|
||||||
|
order: number;
|
||||||
|
parallelWithPrev?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBlockRange {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Разбивает строки (уже отсортированные по order) на шаги — блоки с общим order. */
|
||||||
|
export function blockRanges<T extends IOrderedItem>(rows: T[]): IBlockRange[] {
|
||||||
|
const ranges: IBlockRange[] = [];
|
||||||
|
rows.forEach((r, i) => {
|
||||||
|
if (i === 0 || !r.parallelWithPrev) ranges.push({ start: i, end: i });
|
||||||
|
else ranges[ranges.length - 1].end = i;
|
||||||
|
});
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blockIndexOf(ranges: IBlockRange[], row: number): number {
|
||||||
|
for (let i = 0; i < ranges.length; i += 1) {
|
||||||
|
if (row >= ranges[i].start && row <= ranges[i].end) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Меняет местами два блока (диапазона индексов), не трогая порядок строк внутри них. */
|
||||||
|
export function swapBlocks<T>(rows: T[], a: IBlockRange, b: IBlockRange): T[] {
|
||||||
|
return [
|
||||||
|
...rows.slice(0, a.start),
|
||||||
|
...rows.slice(b.start, b.end + 1),
|
||||||
|
...rows.slice(a.start, a.end + 1),
|
||||||
|
...rows.slice(b.end + 1)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пересчитывает order начиная с floor+1: у параллельных строк (parallelWithPrev)
|
||||||
|
* order общий с предыдущей, у остальных — на единицу больше. Вызывается после
|
||||||
|
* КАЖДОГО перемещения/переключения параллельности, иначе order разъедется с тем,
|
||||||
|
* что показано на экране. floor — order последней ЗАБЛОКИРОВАННОЙ (уже решённой)
|
||||||
|
* строки фазы; строки после неё не могут получить order <= floor — это и есть
|
||||||
|
* гарантия «нельзя поставить согласующего выше того, кто уже одобрил».
|
||||||
|
*/
|
||||||
|
export function withOrdersFrom<T extends IOrderedItem>(rows: T[], floor: number): T[] {
|
||||||
|
let order = floor;
|
||||||
|
return rows.map((r, i) => {
|
||||||
|
const parallel = i > 0 && !!r.parallelWithPrev;
|
||||||
|
if (!parallel) order += 1;
|
||||||
|
return { ...r, order, parallelWithPrev: parallel };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Заполняет parallelWithPrev по факту совпадения order с предыдущей строкой (строки уже отсортированы по order). */
|
||||||
|
export function markParallel<T extends IOrderedItem>(rows: T[]): T[] {
|
||||||
|
return rows.map((r, i) => ({ ...r, parallelWithPrev: i > 0 && r.order === rows[i - 1].order }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { ComparisonOperator, Employee, HeadType, ProjectRole, RouteStepStatus, TemplateRouteStep } from '../models';
|
||||||
|
import { describeStepCondition, describeStepExecutor, detectExecutorKind } from './stepExecutor';
|
||||||
|
|
||||||
|
function step(overrides: Partial<TemplateRouteStep>): TemplateRouteStep {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
orderNo: 1,
|
||||||
|
executorEmail: null,
|
||||||
|
position: null,
|
||||||
|
projectRole: null,
|
||||||
|
headType: null,
|
||||||
|
status: RouteStepStatus.Approval,
|
||||||
|
routeId: 1,
|
||||||
|
allowReassignToOtherApprovers: false,
|
||||||
|
fieldName: null,
|
||||||
|
operation: null,
|
||||||
|
value: null,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('detectExecutorKind', () => {
|
||||||
|
it('email имеет приоритет над остальными способами', () => {
|
||||||
|
expect(detectExecutorKind({ executorEmail: 'a@tnsu.kz', position: 'Директор' })).toBe('email');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('при отсутствии email — position', () => {
|
||||||
|
expect(detectExecutorKind({ position: 'Директор' })).toBe('position');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('при отсутствии email/position — projectRole, если он не None', () => {
|
||||||
|
expect(detectExecutorKind({ projectRole: ProjectRole.Planner })).toBe('projectRole');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('projectRole=None не считается заданным способом', () => {
|
||||||
|
expect(detectExecutorKind({ projectRole: ProjectRole.None })).toBe('headType');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('если ничего не задано — headType (последний вариант по умолчанию)', () => {
|
||||||
|
expect(detectExecutorKind({})).toBe('headType');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('describeStepExecutor', () => {
|
||||||
|
const employees: Employee[] = [{ fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик' }];
|
||||||
|
|
||||||
|
it('для email найденного сотрудника показывает ФИО и email отдельной строкой', () => {
|
||||||
|
const result = describeStepExecutor(step({ executorEmail: 'gorbenko.pavel@tnsu.kz' }), employees);
|
||||||
|
expect(result).toEqual({ primary: 'Горбенко Павел', secondary: 'gorbenko.pavel@tnsu.kz' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('для email, которого нет в справочнике, показывает сам email без secondary', () => {
|
||||||
|
const result = describeStepExecutor(step({ executorEmail: 'unknown@tnsu.kz' }), employees);
|
||||||
|
expect(result).toEqual({ primary: 'unknown@tnsu.kz' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('для шага по должности показывает должность', () => {
|
||||||
|
const result = describeStepExecutor(step({ position: 'Главный бухгалтер' }), employees);
|
||||||
|
expect(result.primary).toBe('Главный бухгалтер');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('для шага по проектной роли показывает подпись роли', () => {
|
||||||
|
const result = describeStepExecutor(step({ projectRole: ProjectRole.Planner }), employees);
|
||||||
|
expect(result.primary).toEqual(expect.stringContaining('Роль:'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('для шага по типу руководителя показывает подпись типа', () => {
|
||||||
|
const result = describeStepExecutor(step({ headType: HeadType.Chief }), employees);
|
||||||
|
expect(result.primary).toEqual(expect.stringContaining('Руководитель:'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('если у шага вообще не задан исполнитель — пустое значение', () => {
|
||||||
|
const result = describeStepExecutor(step({}), employees);
|
||||||
|
expect(result.secondary).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('describeStepCondition', () => {
|
||||||
|
it('undefined, если условие не задано целиком', () => {
|
||||||
|
expect(describeStepCondition(step({}))).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undefined, если operation === None', () => {
|
||||||
|
expect(describeStepCondition(step({ fieldName: 'Amount', operation: ComparisonOperator.None, value: '100' }))).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('собирает "поле оператор значение", если условие задано полностью', () => {
|
||||||
|
const result = describeStepCondition(step({ fieldName: 'Amount', operation: ComparisonOperator.GreaterThan, value: '100' }));
|
||||||
|
expect(result).toBe('Amount > 100');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,12 +3,14 @@ import { comparisonOperatorSymbols, headTypeLabels, projectRoleLabels, texts } f
|
|||||||
import { findEmployeeByEmail } from './employee';
|
import { findEmployeeByEmail } from './employee';
|
||||||
|
|
||||||
/** Каким из четырёх взаимоисключающих способов у шага задан исполнитель. */
|
/** Каким из четырёх взаимоисключающих способов у шага задан исполнитель. */
|
||||||
|
/* eslint-disable @rushstack/no-new-null -- форма зеркалит TemplateRouteStep (nullable JSON-контракт бэкенда) */
|
||||||
export function detectExecutorKind(step: {
|
export function detectExecutorKind(step: {
|
||||||
executorEmail?: string | null;
|
executorEmail?: string | null;
|
||||||
position?: string | null;
|
position?: string | null;
|
||||||
projectRole?: ProjectRole | null;
|
projectRole?: ProjectRole | null;
|
||||||
headType?: HeadType | null;
|
headType?: HeadType | null;
|
||||||
}): StepExecutorKind {
|
}): StepExecutorKind {
|
||||||
|
/* eslint-enable @rushstack/no-new-null */
|
||||||
if (step.executorEmail) return 'email';
|
if (step.executorEmail) return 'email';
|
||||||
if (step.position) return 'position';
|
if (step.position) return 'position';
|
||||||
if (step.projectRole && step.projectRole !== ProjectRole.None) return 'projectRole';
|
if (step.projectRole && step.projectRole !== ProjectRole.None) return 'projectRole';
|
||||||
@@ -24,7 +26,7 @@ export interface StepExecutorDisplay {
|
|||||||
/** Колонка «Исполнитель» в таблице шагов шаблона маршрута. */
|
/** Колонка «Исполнитель» в таблице шагов шаблона маршрута. */
|
||||||
export function describeStepExecutor(step: TemplateRouteStep, employees: Employee[]): StepExecutorDisplay {
|
export function describeStepExecutor(step: TemplateRouteStep, employees: Employee[]): StepExecutorDisplay {
|
||||||
if (step.executorEmail) {
|
if (step.executorEmail) {
|
||||||
const employee = findEmployeeByEmail(employees, step.executorEmail);
|
const employee = findEmployeeByEmail(employees, step.executorEmail ?? undefined);
|
||||||
return employee ? { primary: employee.fio, secondary: employee.email } : { primary: step.executorEmail };
|
return employee ? { primary: employee.fio, secondary: employee.email } : { primary: step.executorEmail };
|
||||||
}
|
}
|
||||||
if (step.position) return { primary: step.position };
|
if (step.position) return { primary: step.position };
|
||||||
|
|||||||
Reference in New Issue
Block a user