Управление заявками #7
@@ -7,6 +7,7 @@ import { PortalDictionaryClient } from '../../services/portalDictionary';
|
|||||||
import { SharePointListsClient } from '../../services/sharePointLists';
|
import { SharePointListsClient } from '../../services/sharePointLists';
|
||||||
import { RequestManageService } from '../../services/requestManageService';
|
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';
|
||||||
@@ -23,76 +24,84 @@ export interface IAdminAppProps {
|
|||||||
currentUserEmail?: string;
|
currentUserEmail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTIVE_TAB_STORAGE_KEY = 'approvalAdmin.activeTab';
|
|
||||||
|
|
||||||
// 'routeSteps' сознательно НЕ входит: этот экран требует selectedRoute — полный
|
|
||||||
// объект маршрута, полученный по клику из RoutesScreen, а не просто ключ. После
|
|
||||||
// перезагрузки страницы такого объекта нет и взять его неоткуда, поэтому эту
|
|
||||||
// вкладку как «последнюю открытую» восстанавливать нельзя — если она вдруг
|
|
||||||
// окажется в localStorage (старое значение, ручная правка), откатываемся на дашборд.
|
|
||||||
const RESTORABLE_SCREENS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'manageRequests'];
|
|
||||||
|
|
||||||
function readStoredScreen(): ScreenKey {
|
|
||||||
try {
|
|
||||||
const raw = window.localStorage.getItem(ACTIVE_TAB_STORAGE_KEY);
|
|
||||||
if (raw && (RESTORABLE_SCREENS as string[]).indexOf(raw) !== -1) return raw as ScreenKey;
|
|
||||||
} catch {
|
|
||||||
// localStorage недоступен (приватный режим и т.п.) — просто не восстанавливаем.
|
|
||||||
}
|
|
||||||
return 'dashboard';
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint, requestManage, currentUserEmail }) => {
|
export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint, requestManage, currentUserEmail }) => {
|
||||||
const [screen, setScreenState] = React.useState<ScreenKey>(readStoredScreen);
|
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>();
|
||||||
|
|
||||||
// 'routeSteps' в localStorage не пишем — см. RESTORABLE_SCREENS.
|
const navigateTab = (key: ScreenKey): void => navigate({ tab: key });
|
||||||
const setScreen = (key: ScreenKey): void => {
|
|
||||||
setScreenState(key);
|
|
||||||
if (key === 'routeSteps') return;
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(ACTIVE_TAB_STORAGE_KEY, key);
|
|
||||||
} catch {
|
|
||||||
// не критично — просто не запомнится
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const navigate = (key: ScreenKey): void => {
|
const openSteps = (r: TemplateRoute): void => {
|
||||||
setScreen(key);
|
setSelectedRoute(r);
|
||||||
};
|
|
||||||
|
|
||||||
const openSteps = (route: TemplateRoute): void => {
|
|
||||||
setSelectedRoute(route);
|
|
||||||
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}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{screen === 'manageRequests' && (
|
{route.tab === 'manageRequests' && (
|
||||||
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} />
|
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
|
|||||||
const isGhost = r.id < 0;
|
const isGhost = r.id < 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={r.id} className={block.end > block.start ? styles.parallelRow : undefined}>
|
<tr key={r.id}>
|
||||||
{isBlockStart && (
|
{isBlockStart && (
|
||||||
<td className={styles.stepCol} rowSpan={block.end - block.start + 1}>
|
<td className={styles.stepCol} rowSpan={block.end - block.start + 1}>
|
||||||
{r.parallelWithPrev || block.end > block.start ? (
|
{r.parallelWithPrev || block.end > block.start ? (
|
||||||
@@ -202,7 +202,7 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
|
|||||||
<ActionsCell>
|
<ActionsCell>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="people"
|
icon="people"
|
||||||
className={styles.iconBtnVisible}
|
className={r.parallelWithPrev ? styles.iconBtnActive : styles.iconBtnVisible}
|
||||||
label={r.parallelWithPrev ? texts.manageRequests.splitFromPrev : texts.manageRequests.mergeWithPrev}
|
label={r.parallelWithPrev ? texts.manageRequests.splitFromPrev : texts.manageRequests.mergeWithPrev}
|
||||||
disabled={busy || !canToggle}
|
disabled={busy || !canToggle}
|
||||||
onClick={() => toggleParallel(i)}
|
onClick={() => toggleParallel(i)}
|
||||||
|
|||||||
+15
-6
@@ -45,12 +45,6 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Строки одного параллельного блока — лёгкая заливка, чтобы объединённая
|
|
||||||
// ячейка «Шаг» читалась как один блок, а не как случайно схлопнутые строки.
|
|
||||||
.parallelRow > td {
|
|
||||||
background: rgba($gold-500, .06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.flipY {
|
.flipY {
|
||||||
svg { transform: scaleY(-1); }
|
svg { transform: scaleY(-1); }
|
||||||
}
|
}
|
||||||
@@ -66,6 +60,21 @@
|
|||||||
background: #fff !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.
|
// повторяет request-portal/shared/approval/ui/ApprovalMatrix.tsx/.matrixPicker.
|
||||||
.addRow {
|
.addRow {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
|||||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||||
import { findEmployeeByEmail } from '../../utils/employee';
|
import { findEmployeeByEmail } from '../../utils/employee';
|
||||||
import { markParallel, withOrdersFrom } from '../../utils/orderBlocks';
|
import { markParallel, withOrdersFrom } from '../../utils/orderBlocks';
|
||||||
|
import { IManageRequestRouteParams, replaceRoute } from '../../routing';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
AsyncSearchableSelect,
|
AsyncSearchableSelect,
|
||||||
@@ -39,6 +40,9 @@ export interface IManageRequestsScreenProps {
|
|||||||
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||||
dictionary?: PortalDictionaryClient;
|
dictionary?: PortalDictionaryClient;
|
||||||
requestManage: RequestManageService;
|
requestManage: RequestManageService;
|
||||||
|
/** Заполнение из URL (глубокая ссылка на конкретную заявку) — есть, только
|
||||||
|
* если в адресе заданы aamtype/aamreq (см. routing.ts). */
|
||||||
|
route?: IManageRequestRouteParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Цвет плашки статуса — те же группы смысла, что и в request-portal
|
// Цвет плашки статуса — те же группы смысла, что и в request-portal
|
||||||
@@ -97,12 +101,39 @@ const SELECTION_STORAGE_KEY = 'approvalAdmin.manageRequestsSelection';
|
|||||||
// решение через request-portal), а вкладка могла быть открыта давно.
|
// решение через request-portal), а вкладка могла быть открыта давно.
|
||||||
const AUTO_REFRESH_MS = 2 * 60 * 1000;
|
const AUTO_REFRESH_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api, dictionary, requestManage }) => {
|
export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api, dictionary, requestManage, route }) => {
|
||||||
const requestTypes = useRequestTypes(api);
|
const requestTypes = useRequestTypes(api);
|
||||||
const employees = usePortalEmployees(dictionary);
|
const employees = usePortalEmployees(dictionary);
|
||||||
|
|
||||||
const [selection, setSelection] = useLocalStorageState<IPersistedSelection>(SELECTION_STORAGE_KEY, EMPTY_SELECTION);
|
const [selection, setSelection] = useLocalStorageState<IPersistedSelection>(SELECTION_STORAGE_KEY, EMPTY_SELECTION);
|
||||||
const { requestTypeId, selectedRequest } = 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 [matrix, setMatrix] = React.useState<ManagedRequestMatrix | undefined>();
|
||||||
const [matrixLoading, setMatrixLoading] = React.useState(false);
|
const [matrixLoading, setMatrixLoading] = React.useState(false);
|
||||||
const [matrixError, setMatrixError] = React.useState<string | undefined>();
|
const [matrixError, setMatrixError] = React.useState<string | undefined>();
|
||||||
@@ -156,7 +187,17 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
setMatrixError(undefined);
|
setMatrixError(undefined);
|
||||||
requestManage
|
requestManage
|
||||||
.getMatrix(requestType, selectedRequest.id)
|
.getMatrix(requestType, selectedRequest.id)
|
||||||
.then(setMatrix)
|
.then(m => {
|
||||||
|
setMatrix(m);
|
||||||
|
// title/status в комбобоксе (а значит, и в localStorage) держим
|
||||||
|
// синхронными с сервером — особенно важно после захода по глубокой
|
||||||
|
// ссылке, где изначально они пустые (см. эффект-затравку выше).
|
||||||
|
setSelection(prev =>
|
||||||
|
prev.selectedRequest && prev.selectedRequest.id === m.request.id
|
||||||
|
? { ...prev, selectedRequest: m.request }
|
||||||
|
: prev
|
||||||
|
);
|
||||||
|
})
|
||||||
.catch((e: Error) => setMatrixError(e.message))
|
.catch((e: Error) => setMatrixError(e.message))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!silent) setMatrixLoading(false);
|
if (!silent) setMatrixLoading(false);
|
||||||
|
|||||||
@@ -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,11 +67,36 @@ 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, requestType?.listId);
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
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'
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseTab(raw: string | null): ScreenKey {
|
||||||
|
return raw && (TABS as string[]).indexOf(raw) !== -1 ? (raw as ScreenKey) : 'dashboard';
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user