Merge branch 'main' of http://git.tnsu.kz/timur.gorodetskiy/request-portal
This commit is contained in:
@@ -6,6 +6,34 @@
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.tileWrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.allRequestsButton {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-md;
|
||||
background: $white;
|
||||
color: $gray-400;
|
||||
cursor: pointer;
|
||||
transition: color .18s, border-color .18s;
|
||||
|
||||
&:hover {
|
||||
color: $gold-600;
|
||||
border-color: $gold-400;
|
||||
}
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -7,9 +7,18 @@ import { useIsTester } from '../../shared/useIsTester';
|
||||
interface IRequestGridProps {
|
||||
requests: IRequestType[];
|
||||
onSelect: (request: IRequestType) => void;
|
||||
/** pageUrl → RequestTypeId, только для типов с доступом к «Все заявки» (см. useAccessibleAllRequestsFormKeys). */
|
||||
accessibleAllRequestsIds?: Map<string, number>;
|
||||
/** Клик по кнопке «Все заявки» на плитке — задан вместе с accessibleAllRequestsIds. */
|
||||
onOpenAllRequests?: (requestTypeId: number) => void;
|
||||
}
|
||||
|
||||
export const RequestGrid: React.FC<IRequestGridProps> = ({ requests, onSelect }) => {
|
||||
export const RequestGrid: React.FC<IRequestGridProps> = ({
|
||||
requests,
|
||||
onSelect,
|
||||
accessibleAllRequestsIds,
|
||||
onOpenAllRequests,
|
||||
}) => {
|
||||
// Группа «Тестирование» видит формы «в разработке» открытыми — чтобы проверять
|
||||
// их до запуска для всех (см. shared/useIsTester).
|
||||
const isTester = useIsTester() === true;
|
||||
@@ -22,9 +31,10 @@ export const RequestGrid: React.FC<IRequestGridProps> = ({ requests, onSelect })
|
||||
<div className={styles.grid}>
|
||||
{requests.map((request) => {
|
||||
const isLocked = !!request.comingSoon && !isTester;
|
||||
const allRequestsTypeId = accessibleAllRequestsIds?.get(request.id);
|
||||
return (
|
||||
<div key={request.id} className={styles.tileWrap}>
|
||||
<button
|
||||
key={request.id}
|
||||
type="button"
|
||||
className={isLocked ? `${styles.tile} ${styles.tileDisabled}` : styles.tile}
|
||||
disabled={isLocked}
|
||||
@@ -49,6 +59,17 @@ export const RequestGrid: React.FC<IRequestGridProps> = ({ requests, onSelect })
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{allRequestsTypeId !== undefined && onOpenAllRequests && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.allRequestsButton}
|
||||
title="Все заявки этого типа"
|
||||
onClick={() => onOpenAllRequests(allRequestsTypeId)}
|
||||
>
|
||||
<Icon name="people" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,13 @@ import type { ISidebarItem } from '@tnsu/ui-kit';
|
||||
import { ConfirmHost } from '../shared/ui';
|
||||
import {
|
||||
navigate,
|
||||
persistAllTypeId,
|
||||
persistNav,
|
||||
persistOwnTab,
|
||||
persistOwnStatus,
|
||||
persistPage,
|
||||
persistQuery,
|
||||
storedAllTypeId,
|
||||
storedOwnStatus,
|
||||
storedOwnTab,
|
||||
storedPageFor,
|
||||
@@ -34,11 +36,13 @@ import EditFeature from '../features/edit/EditFeature';
|
||||
import { ListView, RequestsTableView } from '../features/myRequests/RequestsTableView';
|
||||
import { OwnTabs } from '../features/myRequests/OwnTabs';
|
||||
import { SearchView } from '../features/myRequests/SearchView';
|
||||
import { getNavCounts, INavCounts } from '../features/myRequests/myRequests.service';
|
||||
import { getLinkedTypes, getNavCounts, INavCounts } from '../features/myRequests/myRequests.service';
|
||||
import { IMyRequest } from '../features/myRequests/models/IMyRequest';
|
||||
import { isEditableStatus } from '../shared/approval';
|
||||
import { hasReportsAccess } from '../shared/reports/reportsAccess';
|
||||
import { ReportsFeature } from '../features/reports/ReportsFeature';
|
||||
import { AllRequestsFeature } from '../features/myRequests/AllRequestsFeature';
|
||||
import { accessibleAllRequestsTypes } from '../shared/approval';
|
||||
|
||||
// Оболочка портала: тёмное боковое меню (разделы) + основная область.
|
||||
// Логики фич тут нет — файл общий, меняется редко.
|
||||
@@ -102,6 +106,33 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
}
|
||||
}, [route.nav, canSeeReports]);
|
||||
|
||||
// Раздел «Все заявки» — виден только тем, кому в админке approval-route
|
||||
// выдан RequestTypeAccess хотя бы на один тип (см. shared/approval/
|
||||
// requestTypeAccess.ts). undefined — ответ ещё не пришёл.
|
||||
const [canSeeAllRequests, setCanSeeAllRequests] = React.useState<boolean | undefined>(undefined);
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
setCanSeeAllRequests(undefined);
|
||||
getLinkedTypes(context)
|
||||
.then((types) => accessibleAllRequestsTypes(context, types))
|
||||
.then((accessible) => {
|
||||
if (!cancelled) setCanSeeAllRequests(accessible.length > 0);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setCanSeeAllRequests(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [context, impersonatedEmail]);
|
||||
|
||||
// Та же защита от прямой ссылки, что и у «Отчётов» выше.
|
||||
React.useEffect(() => {
|
||||
if (route.nav === 'allRequests' && canSeeAllRequests === false) {
|
||||
navigate({ mode: 'submit', nav: 'new' }, { replace: true });
|
||||
}
|
||||
}, [route.nav, canSeeAllRequests]);
|
||||
|
||||
// Уникальный код заявки для заголовка Drawer (approve/edit) — сама форма
|
||||
// узнаёт его только после загрузки (см. onCodeLoaded в ApproveFeature/
|
||||
// EditFeature), поэтому не берём route.itemId (это RowID в SharePoint, а не
|
||||
@@ -201,6 +232,19 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Тип заявки в «Все заявки» — из запомненного (см. gotoAllRequestsType
|
||||
// ниже, откуда он и запоминается). Не задан ни разу — undefined, раздел
|
||||
// сам выберет первый доступный тип (см. AllRequestsFeature).
|
||||
if (nav === 'allRequests') {
|
||||
navigate({
|
||||
mode: 'submit',
|
||||
nav,
|
||||
allTypeId: storedAllTypeId(),
|
||||
page: storedPageFor(nav),
|
||||
query: storedQueryFor(nav) || undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
navigate({
|
||||
mode: 'submit',
|
||||
nav,
|
||||
@@ -224,6 +268,28 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
});
|
||||
};
|
||||
|
||||
// Переход в «Все заявки» — либо с бокового меню (typeId не задан, раздел сам
|
||||
// выберет тип), либо с кнопки формы (typeId уже известен, см.
|
||||
// gotoAllRequestsForType и SubmitFeature.onOpenAllRequests), либо смена типа
|
||||
// в самом разделе (пикер в AllRequestsFeature). Запоминаем выбор — как и
|
||||
// persistOwnTab, только при явном выборе (в т.ч. том, что раздел сделал
|
||||
// сам по умолчанию), чтобы следующий заход открыл тот же тип.
|
||||
const gotoAllRequestsType = (typeId: number): void => {
|
||||
persistAllTypeId(typeId);
|
||||
navigate({
|
||||
mode: 'submit',
|
||||
nav: 'allRequests',
|
||||
allTypeId: typeId,
|
||||
page: storedPageFor('allRequests'),
|
||||
query: storedQueryFor('allRequests') || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const gotoAllRequestsForType = (typeId: number): void => {
|
||||
persistNav('allRequests');
|
||||
gotoAllRequestsType(typeId);
|
||||
};
|
||||
|
||||
const setOwnStatus = (ownStatus: string): void => {
|
||||
const normalized = ownStatus || undefined;
|
||||
persistOwnStatus(normalized);
|
||||
@@ -281,6 +347,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
listId: request.listId,
|
||||
ownTab: route.ownTab,
|
||||
ownStatus: route.ownStatus,
|
||||
allTypeId: route.allTypeId,
|
||||
// Страница и запрос — часть того же списка под панелью: без них ссылка на
|
||||
// открытую заявку вела бы на первую страницу без поиска.
|
||||
page: route.page,
|
||||
@@ -295,6 +362,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
nav: route.nav,
|
||||
ownTab: route.ownTab,
|
||||
ownStatus: route.ownStatus,
|
||||
allTypeId: route.allTypeId,
|
||||
page: route.page,
|
||||
query: route.query,
|
||||
});
|
||||
@@ -324,6 +392,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
{ id: 'rework', title: 'Доработка', icon: 'edit', count: navCounts?.rework },
|
||||
{ id: 'own', title: 'Мои заявки', icon: 'clipboard' },
|
||||
{ id: 'search', title: 'Поиск', icon: 'search' },
|
||||
...(canSeeAllRequests ? [{ id: 'allRequests', title: 'Все заявки', icon: 'people' } as ISidebarItem] : []),
|
||||
...(canSeeReports ? [{ id: 'reports', title: 'Отчёты', icon: 'chart' } as ISidebarItem] : []),
|
||||
{ id: 'instructions', title: 'Инструкция пользователя', icon: 'info' },
|
||||
];
|
||||
@@ -413,6 +482,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
: undefined
|
||||
}
|
||||
onFormSelected={(formKey) => navigate({ mode: 'create', nav: 'new', formKey })}
|
||||
onOpenAllRequests={gotoAllRequestsForType}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -433,6 +503,19 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
|
||||
{route.nav === 'reports' && canSeeReports && <ReportsFeature context={context} />}
|
||||
|
||||
{route.nav === 'allRequests' && canSeeAllRequests && (
|
||||
<AllRequestsFeature
|
||||
context={context}
|
||||
selectedTypeId={route.allTypeId}
|
||||
onTypeChange={gotoAllRequestsType}
|
||||
page={route.page ?? 1}
|
||||
onPageChange={setPage}
|
||||
query={route.query ?? ''}
|
||||
onQueryChange={setQuery}
|
||||
onOpen={openRequest}
|
||||
/>
|
||||
)}
|
||||
|
||||
{LIST_VIEWS[route.nav] !== undefined && (
|
||||
<RequestsTableView
|
||||
context={context}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.typePicker {
|
||||
max-width: 360px;
|
||||
margin-bottom: $sp-4;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: $sp-6;
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-md;
|
||||
color: $gray-500;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as React from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { Alert, Select } from '@tnsu/ui-kit';
|
||||
import { accessibleAllRequestsTypes, IRequestType } from '../../shared/approval';
|
||||
import { RequestsTableView } from './RequestsTableView';
|
||||
import { getLinkedTypes } from './myRequests.service';
|
||||
import { IMyRequest } from './models/IMyRequest';
|
||||
import styles from './AllRequestsFeature.module.scss';
|
||||
|
||||
export interface IAllRequestsFeatureProps {
|
||||
context: WebPartContext;
|
||||
/** Тип из маршрута (rpalltype) — сохраняется в адресе, чтобы ссылку можно было передать. */
|
||||
selectedTypeId?: number;
|
||||
onTypeChange: (typeId: number) => void;
|
||||
page: number;
|
||||
onPageChange: (page: number) => void;
|
||||
query: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
onOpen: (request: IMyRequest) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Раздел «Все заявки»: доступен только тем, у кого в админке approval-route
|
||||
* настроен RequestTypeAccess хотя бы на один тип заявки (см.
|
||||
* shared/approval/requestTypeAccess.ts). Показывает заявки ВСЕХ авторов
|
||||
* выбранного типа — в отличие от «Моих заявок», где отбор всегда идёт по
|
||||
* текущему пользователю.
|
||||
*/
|
||||
export const AllRequestsFeature: React.FC<IAllRequestsFeatureProps> = ({
|
||||
context,
|
||||
selectedTypeId,
|
||||
onTypeChange,
|
||||
page,
|
||||
onPageChange,
|
||||
query,
|
||||
onQueryChange,
|
||||
onOpen,
|
||||
}) => {
|
||||
const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
|
||||
const [error, setError] = React.useState<string>('');
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
getLinkedTypes(context)
|
||||
.then((all) => accessibleAllRequestsTypes(context, all))
|
||||
.then((accessible) => {
|
||||
if (!cancelled) setTypes(accessible);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError((e as Error).message);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [context]);
|
||||
|
||||
// Тип по умолчанию — из адреса, если он всё ещё доступен, иначе первый
|
||||
// доступный. Отдельным эффектом (а не при рендере): смена выбора идёт через
|
||||
// navigate() у вызывающей стороны, и до его отработки selectedTypeId ещё
|
||||
// хранит предыдущее значение.
|
||||
React.useEffect(() => {
|
||||
if (!types || types.length === 0) return;
|
||||
const stillValid = !!selectedTypeId && types.some((t) => t.id === selectedTypeId);
|
||||
if (!stillValid) onTypeChange(types[0].id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- onTypeChange стабилен по смыслу (см. gotoAllRequestsType в RequestPortal), включение его в deps дало бы лишний повторный запуск на каждый рендер
|
||||
}, [types, selectedTypeId]);
|
||||
|
||||
if (error) {
|
||||
return <Alert variant="err">Не удалось загрузить типы заявок: {error}</Alert>;
|
||||
}
|
||||
if (types === undefined) {
|
||||
return <div className={styles.empty}>Загрузка…</div>;
|
||||
}
|
||||
if (types.length === 0) {
|
||||
return <div className={styles.empty}>Вам не выдан доступ ни к одному типу заявок.</div>;
|
||||
}
|
||||
|
||||
const selectedType = types.find((t) => t.id === selectedTypeId) ?? types[0];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{types.length > 1 && (
|
||||
<div className={styles.typePicker}>
|
||||
<Select
|
||||
value={String(selectedType.id)}
|
||||
onChange={(v) => onTypeChange(Number(v))}
|
||||
options={types.map((t) => ({ value: String(t.id), label: t.name }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<RequestsTableView
|
||||
context={context}
|
||||
view="all"
|
||||
allRequestsType={selectedType}
|
||||
page={page}
|
||||
onPageChange={onPageChange}
|
||||
query={query}
|
||||
onQueryChange={onQueryChange}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import {
|
||||
ApprovalQueueKind,
|
||||
filterPendingByQuery,
|
||||
getAllRequestsForType,
|
||||
getApprovalQueue,
|
||||
getApprovalQueueIndex,
|
||||
getLinkedTypes,
|
||||
@@ -50,7 +51,8 @@ export type ListView =
|
||||
| 'own:created'
|
||||
| 'own:approved'
|
||||
| 'own:rejected'
|
||||
| 'own:executed';
|
||||
| 'own:executed'
|
||||
| 'all';
|
||||
|
||||
// Представления, которые строятся на строках маршрута пользователя, и их очередь
|
||||
// в сервисе. Всё, чего здесь нет ('rework'/'own:created'), читается из списка
|
||||
@@ -63,6 +65,9 @@ const QUEUE_BY_VIEW: { [key: string]: ApprovalQueueKind } = {
|
||||
'own:executed': 'executed',
|
||||
};
|
||||
|
||||
// 'all' — заголовок и текст пустоты зависят от выбранного типа заявки
|
||||
// (см. allRequestsType и heading/emptyText ниже), здесь только запасной
|
||||
// вариант на случай, пока тип ещё не выбран/не загрузился.
|
||||
const HEADINGS: Record<ListView, string> = {
|
||||
approvals: 'Ждут вашего согласования',
|
||||
execution: 'Ждут вашего исполнения',
|
||||
@@ -71,6 +76,7 @@ const HEADINGS: Record<ListView, string> = {
|
||||
'own:approved': 'Согласованные мной',
|
||||
'own:rejected': 'Отклонённые мной',
|
||||
'own:executed': 'Выполненные мной',
|
||||
all: 'Все заявки',
|
||||
};
|
||||
|
||||
const EMPTY_TEXTS: Record<ListView, string> = {
|
||||
@@ -81,6 +87,7 @@ const EMPTY_TEXTS: Record<ListView, string> = {
|
||||
'own:approved': 'Вы ещё ничего не согласовали.',
|
||||
'own:rejected': 'Вы ещё ничего не отклоняли.',
|
||||
'own:executed': 'Вы ещё ничего не выполнили.',
|
||||
all: 'Заявок этого типа пока нет.',
|
||||
};
|
||||
|
||||
// Цвет пункта в выпадающем списке фильтра — то же сопоставление статус→цвет,
|
||||
@@ -157,6 +164,8 @@ function columnsFor(view: ListView, statusFilter: string): ITableColumns {
|
||||
if (view === 'approvals' || view === 'execution') return { author: true, status: false };
|
||||
if (view === 'rework') return { author: false, status: false, commentLabel };
|
||||
if (view === 'own:created') return { author: false, status: !statusFilter, commentLabel };
|
||||
// 'all' — заявки всех авторов вперемешку, поэтому нужны и автор, и статус.
|
||||
if (view === 'all') return { author: true, status: true };
|
||||
// История решений: заявка чужая, поэтому нужен автор. Статус — только если он
|
||||
// не предопределён самой вкладкой («Отклонённые мной» — это всегда «Отклонено»,
|
||||
// а вот у согласованных/выполненных заявка могла уйти дальше по маршруту).
|
||||
@@ -185,6 +194,8 @@ export interface IRequestsTableViewProps {
|
||||
onQueryChange: (query: string) => void;
|
||||
/** Клик по строке — открыть заявку (в выезжающей панели). */
|
||||
onOpen: (request: IMyRequest) => void;
|
||||
/** Только при view='all' — тип заявки, чьи заявки показываются целиком (без фильтра по автору). */
|
||||
allRequestsType?: IRequestType;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
@@ -330,6 +341,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
query,
|
||||
onQueryChange,
|
||||
onOpen,
|
||||
allRequestsType,
|
||||
}) => {
|
||||
const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
|
||||
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
|
||||
@@ -361,7 +373,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
// по чужому totalRows и сбивал страницу на первую — из-за чего запомненная
|
||||
// страница затиралась при каждом переходе по левому меню.
|
||||
// Страница в ключ НЕ входит: totalRows один на всю таблицу, а не на страницу.
|
||||
const dataKey = `${view}|${statusFilter}|${debouncedQuery.trim()}`;
|
||||
const dataKey = `${view}|${statusFilter}|${debouncedQuery.trim()}|${allRequestsType?.id ?? ''}`;
|
||||
const [loadedKey, setLoadedKey] = React.useState<string>('');
|
||||
|
||||
// Страницу при смене запроса здесь НЕ сбрасываем: и запрос, и страница живут
|
||||
@@ -384,7 +396,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
React.useEffect(() => {
|
||||
if (types === undefined) return;
|
||||
let cancelled = false;
|
||||
const cacheKey = pageKey('table', [view, statusFilter, debouncedQuery.trim(), page]);
|
||||
const cacheKey = pageKey('table', [view, statusFilter, debouncedQuery.trim(), page, allRequestsType?.id ?? 0]);
|
||||
|
||||
// Сразу показываем то, что уже загружали для ЭТОЙ вкладки (и этого запроса
|
||||
// и страницы), а обновление идёт фоном. Компонент один на обе вкладки —
|
||||
@@ -422,6 +434,11 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
: getOwnRequests(context, types, page, onPartial);
|
||||
}
|
||||
|
||||
if (view === 'all') {
|
||||
if (!allRequestsType) return { rows: [], totalRows: 0 };
|
||||
return getAllRequestsForType(context, allRequestsType, page, debouncedQuery);
|
||||
}
|
||||
|
||||
const queue = QUEUE_BY_VIEW[view];
|
||||
if (q.length === 0) {
|
||||
// Без поиска — страница уже нарезана и дочитана сервером.
|
||||
@@ -524,7 +541,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
unsubscribe();
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [context, types, view, statusFilter, debouncedQuery, page, dataKey]);
|
||||
}, [context, types, view, statusFilter, debouncedQuery, page, dataKey, allRequestsType]);
|
||||
|
||||
// Все ветки отдают уже нарезанную страницу — просто показываем как есть.
|
||||
const q = debouncedQuery.trim();
|
||||
@@ -561,7 +578,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
<section className={styles.view}>
|
||||
<div className={styles.head}>
|
||||
<h2 className={styles.heading}>
|
||||
{HEADINGS[view]}
|
||||
{view === 'all' && allRequestsType ? `Все заявки: ${allRequestsType.name}` : HEADINGS[view]}
|
||||
{rows && <span className={styles.count}>{total}</span>}
|
||||
</h2>
|
||||
{view === 'own:created' && onStatusFilterChange && (
|
||||
|
||||
@@ -39,8 +39,14 @@ export interface IMyRequest {
|
||||
siteUrl: string;
|
||||
/** Ключ формы (rptype в URL режима согласования) — поле pageUrl из approval-route API. */
|
||||
formKey: string;
|
||||
/** 'own' — заявка, созданная текущим пользователем; 'approval' — ждёт его решения. */
|
||||
kind: 'own' | 'approval';
|
||||
/**
|
||||
* 'own' — заявка, созданная текущим пользователем; 'approval' — ждёт его
|
||||
* решения; 'other' — раздел «Все заявки» (доступ настроен в админке
|
||||
* approval-route): заявка чужая и открывается только на просмотр, как и
|
||||
* 'approval' (см. openRequest в RequestPortal.tsx — по kind !== 'own' решение
|
||||
* то же самое).
|
||||
*/
|
||||
kind: 'own' | 'approval' | 'other';
|
||||
/**
|
||||
* Статус СВОЕЙ строки маршрута (не заявки) — «На согласовании», «Согласовано»
|
||||
* и т.п. Заполняется только у kind='approval'. Нужен, чтобы разнести заявки
|
||||
|
||||
@@ -1140,7 +1140,7 @@ interface ISearchEntry {
|
||||
* компромиссом — searchOwnRequests и раньше не подмешивал REST-подстраховку
|
||||
* (в отличие от getOwnRequests, где её явно добавляет getFreshOwnRowsForType).
|
||||
*/
|
||||
function toMyRequestFromSearch(entry: ISearchEntry): IMyRequest {
|
||||
function toMyRequestFromSearch(entry: ISearchEntry, kind: 'own' | 'other' = 'own'): IMyRequest {
|
||||
const r = entry.raw;
|
||||
const author = parseAuthorOwsUser(r[MP_AUTHOR]);
|
||||
return {
|
||||
@@ -1157,7 +1157,7 @@ function toMyRequestFromSearch(entry: ISearchEntry): IMyRequest {
|
||||
requestTypeName: entry.type.name,
|
||||
section: entry.type.section,
|
||||
formKey: entry.type.pageUrl,
|
||||
kind: 'own',
|
||||
kind,
|
||||
listId: entry.target.listId,
|
||||
siteUrl: entry.target.siteUrl,
|
||||
};
|
||||
@@ -1370,11 +1370,48 @@ export async function searchOwnRequests(
|
||||
const totalRows = results.TotalRows || 0;
|
||||
// Поля — прямо из ответа индекса (см. toMyRequestFromSearch), REST-дочитывания
|
||||
// здесь больше нет. Порядок задаёт релевантность поиска.
|
||||
const rows = hitsToEntries(results, types).map(toMyRequestFromSearch);
|
||||
const rows = hitsToEntries(results, types).map((e) => toMyRequestFromSearch(e));
|
||||
|
||||
return { rows, totalRows };
|
||||
}
|
||||
|
||||
// «Все заявки» ОДНОГО типа — для пользователей, которым в админке
|
||||
// approval-route выдан доступ видеть заявки этого типа целиком, а не только
|
||||
// свои (см. shared/approval/requestTypeAccess.ts). В отличие от
|
||||
// searchOwnRequests — без фильтра по автору, и только ОДИН тип (раздел уже
|
||||
// показывает конкретный выбранный тип, а не все связанные разом). Только
|
||||
// Search, без REST-подстраховки на свежесть — как и у searchOwnRequests.
|
||||
//
|
||||
// Весь найденный срез целиком (fetchAllSearchRows), а не постранично через
|
||||
// Search RowLimit/StartRow: totalRows там был бы сырым счётчиком индекса ДО
|
||||
// дедупа по (тип, Id) (см. hitsToEntries — TrimDuplicates:false намеренно, и
|
||||
// индекс может вернуть один и тот же элемент несколькими хитами), и на
|
||||
// странице оказывалось МЕНЬШЕ строк, чем показывал счётчик над таблицей.
|
||||
// Тип здесь только один — набор в разумных пределах, дедуп и сортировка идут
|
||||
// по всему сразу, страница нарезается уже в памяти.
|
||||
export async function getAllRequestsForType(
|
||||
context: WebPartContext,
|
||||
type: IRequestType,
|
||||
page: number,
|
||||
query: string
|
||||
): Promise<IOwnRequestsPage> {
|
||||
const sp = spForSite(type.siteUrl as string, context);
|
||||
const clauses = [pathClauseForTypes([type])];
|
||||
const text = freeTextClause(query);
|
||||
if (text) clauses.push(text);
|
||||
const kql = clauses.join(' AND ');
|
||||
|
||||
const hits = await traced('Search: все заявки типа', () =>
|
||||
fetchAllSearchRows(sp, kql, OWN_SELECT_PROPERTIES, 'Все заявки типа', [{ Property: 'Created', Direction: 1 }])
|
||||
);
|
||||
|
||||
// kind: 'other' — заявка не обязательно текущего пользователя, открывается
|
||||
// только на просмотр (см. IMyRequest.kind, openRequest в RequestPortal.tsx).
|
||||
const rows = hitsToEntries({ PrimarySearchResults: hits }, [type]).map((e) => toMyRequestFromSearch(e, 'other'));
|
||||
|
||||
return { rows: rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), totalRows: rows.length };
|
||||
}
|
||||
|
||||
// Свои заявки с конкретным статусом ЗАЯВКИ (не строки маршрута) — «Доработка»
|
||||
// и фильтр по статусу на вкладке «Инициированные».
|
||||
//
|
||||
@@ -1446,7 +1483,7 @@ async function ownRequestsByStatus(
|
||||
SortList: [{ Property: 'Created', Direction: 1 }],
|
||||
})
|
||||
).then((results) => ({
|
||||
rows: hitsToEntries(results, types).map(toMyRequestFromSearch),
|
||||
rows: hitsToEntries(results, types).map((e) => toMyRequestFromSearch(e)),
|
||||
totalRows: results.TotalRows || 0,
|
||||
}));
|
||||
|
||||
@@ -1672,7 +1709,7 @@ export async function searchOwnAll(
|
||||
traceSearchHits('Свои заявки', kql, types, results, listAbsUrl);
|
||||
|
||||
const entries = hitsToEntries(results, types);
|
||||
const rows = entries.map(toMyRequestFromSearch);
|
||||
const rows = entries.map((e) => toMyRequestFromSearch(e));
|
||||
|
||||
traceResultRows('Свои заявки', types, entries, rows);
|
||||
return rows;
|
||||
|
||||
@@ -49,3 +49,29 @@
|
||||
font-size: $fs-sm;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.formToolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: $sp-4;
|
||||
}
|
||||
|
||||
.allRequestsButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
padding: $sp-2 $sp-4;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-md;
|
||||
background: $white;
|
||||
color: $navy-700;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-sm;
|
||||
cursor: pointer;
|
||||
transition: color .18s, border-color .18s;
|
||||
|
||||
&:hover {
|
||||
color: $gold-600;
|
||||
border-color: $gold-600;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { IRequestType } from '../../models/types';
|
||||
import { emitRequestsChanged } from '../../shared/requestEvents';
|
||||
import { confirmDiscardFormChanges, markFormClean } from '../../shared/unsavedChanges';
|
||||
import { useIsTester } from '../../shared/useIsTester';
|
||||
import { useAccessibleAllRequestsFormKeys } from '../../shared/approval';
|
||||
|
||||
export interface ISubmitFeatureProps {
|
||||
context: WebPartContext;
|
||||
@@ -25,6 +26,12 @@ export interface ISubmitFeatureProps {
|
||||
/** Пользователь выбрал плитку — сообщаем наверх, чтобы адрес страницы стал
|
||||
* ссылкой на эту пустую форму (см. newRequestLinkUrl в shared/routing). */
|
||||
onFormSelected?: (formKey: string) => void;
|
||||
/**
|
||||
* Кнопка «Все заявки этого типа» на форме — только когда в админке
|
||||
* approval-route текущему пользователю выдан доступ к этому типу (см.
|
||||
* hasRequestTypeAccess ниже). Ведёт в раздел «Все заявки» (nav='allRequests').
|
||||
*/
|
||||
onOpenAllRequests?: (requestTypeId: number) => void;
|
||||
}
|
||||
|
||||
// Фича «Подать заявку»: плитки → беспоук-форма (категории/вкладки — в RequestPortal).
|
||||
@@ -35,6 +42,7 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({
|
||||
initialForm,
|
||||
editForm,
|
||||
onFormSelected,
|
||||
onOpenAllRequests,
|
||||
}) => {
|
||||
// «Создать на основе» и «редактировать» оба сразу открывают нужную форму, минуя плитки.
|
||||
const preOpen = initialForm ?? editForm;
|
||||
@@ -60,6 +68,13 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({
|
||||
const requests = getRequestsByCategory(activeCategoryId);
|
||||
const isTester = useIsTester();
|
||||
|
||||
// Кнопка «Все заявки этого типа» — и на плитке, и на открытой форме, только
|
||||
// если текущему пользователю в админке approval-route выдан доступ к этому
|
||||
// типу (см. shared/approval/requestTypeAccess.ts). Один общий поход по всем
|
||||
// типам разом (formKey формы совпадает с pageUrl approval-route по
|
||||
// соглашению, см. useRequestTypeId) — не по одному запросу на каждую плитку.
|
||||
const accessibleAllRequestsIds = useAccessibleAllRequestsFormKeys(context);
|
||||
|
||||
// Возврат к плиткам закрывает форму и теряет введённое (раздел остаётся
|
||||
// смонтированным, но состояние формы живёт в самом FormComponent). Спрашиваем,
|
||||
// если правки есть и они не сохранены; после согласия флаг снимаем — терять
|
||||
@@ -102,8 +117,23 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({
|
||||
// которой они пришли (formKey совпал).
|
||||
const external = initialForm && request.id === initialForm.formKey ? initialForm : undefined;
|
||||
|
||||
const allRequestsTypeId = accessibleAllRequestsIds?.get(request.id);
|
||||
|
||||
// Ленивый рендер формы: её код подтягивается только сейчас, при открытии.
|
||||
return (
|
||||
<>
|
||||
{allRequestsTypeId !== undefined && onOpenAllRequests && (
|
||||
<div className={styles.formToolbar}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.allRequestsButton}
|
||||
onClick={() => onOpenAllRequests(allRequestsTypeId)}
|
||||
>
|
||||
<Icon name="people" size="sm" />
|
||||
<span>Все заявки этого типа</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<React.Suspense fallback={<div className={styles.loading}>Загрузка формы…</div>}>
|
||||
<FormComponent
|
||||
requestType={request}
|
||||
@@ -117,6 +147,7 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({
|
||||
storeName={external?.storeName}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -135,7 +166,12 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({
|
||||
{selectedRequest ? (
|
||||
renderSelected(selectedRequest)
|
||||
) : (
|
||||
<RequestGrid requests={requests} onSelect={handleSelect} />
|
||||
<RequestGrid
|
||||
requests={requests}
|
||||
onSelect={handleSelect}
|
||||
accessibleAllRequestsIds={accessibleAllRequestsIds}
|
||||
onOpenAllRequests={onOpenAllRequests}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -147,6 +147,28 @@ export async function getRequestTypeIdByPageUrl(context: WebPartContext, pageUrl
|
||||
return found.id;
|
||||
}
|
||||
|
||||
// GET /api/request-type-access/{requestTypeId}/emails — email'ы, кому в
|
||||
// админке approval-route выдан доступ видеть ВСЕ заявки этого типа (не только
|
||||
// свои). Тот же эндпоинт дёргает и Power Automate — здесь просто ещё один
|
||||
// потребитель.
|
||||
export async function getRequestTypeAccessEmails(
|
||||
context: WebPartContext,
|
||||
requestTypeId: number
|
||||
): Promise<string[]> {
|
||||
return withNetworkRetry(async () => {
|
||||
const c = await client(context);
|
||||
const response: HttpClientResponse = await c.get(
|
||||
`${getApprovalApiBaseUrl()}/api/request-type-access/${requestTypeId}/emails`,
|
||||
AadHttpClient.configurations.v1
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`request-type-access/${requestTypeId}/emails: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const body: IApiResponse<string[]> = await response.json();
|
||||
return body.data ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
// POST /api/template-routes/resolve — планируемый маршрут для типа заявки и
|
||||
// пользователя. Бэкенд сам подставляет исполнителей шага (executorEmail/position)
|
||||
// по ролям исходя из userEmail. Параметр status выбирает ФАЗУ маршрута:
|
||||
|
||||
@@ -31,8 +31,11 @@ export {
|
||||
getRequestTypeIdByPageUrl,
|
||||
getListPartitions,
|
||||
getCurrentListPartition,
|
||||
getRequestTypeAccessEmails,
|
||||
resolveRoute
|
||||
} from './approvalRoute.service';
|
||||
export { hasRequestTypeAccess, accessibleAllRequestsTypes } from './requestTypeAccess';
|
||||
export { useAccessibleAllRequestsFormKeys } from './useAccessibleAllRequestsFormKeys';
|
||||
export {
|
||||
initListPartitions,
|
||||
listPartitionsLoaded,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { getRequestTypeAccessEmails } from './approvalRoute.service';
|
||||
import { IRequestType } from './models/IApprovalRouteApi';
|
||||
import { effectiveUserEmail } from '../impersonation';
|
||||
|
||||
// Кеш на уровне МОДУЛЯ, по requestTypeId — тот же приём, что и у
|
||||
// requestTypesPromise в approvalRoute.service.ts: реестр доступа меняется
|
||||
// только из админки approval-route, а не в течение сеанса портала. Ошибка НЕ
|
||||
// кешируется, чтобы временный сбой сети не «залип» на весь сеанс.
|
||||
const cache = new Map<number, Promise<string[]>>();
|
||||
|
||||
function emailsForType(context: WebPartContext, requestTypeId: number): Promise<string[]> {
|
||||
let pending = cache.get(requestTypeId);
|
||||
if (!pending) {
|
||||
pending = getRequestTypeAccessEmails(context, requestTypeId).catch((e) => {
|
||||
cache.delete(requestTypeId);
|
||||
throw e;
|
||||
});
|
||||
cache.set(requestTypeId, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Есть ли у ТЕКУЩЕГО пользователя (effectiveUserEmail — учитывает отладочную
|
||||
* подмену личности) доступ видеть все заявки этого типа. Ошибка запроса — не
|
||||
* доступ, а не исключение наверх: как и hasReportsAccess, тихий сбой сети не
|
||||
* должен ронять раздел, который его вызывает.
|
||||
*/
|
||||
export async function hasRequestTypeAccess(context: WebPartContext, requestTypeId: number): Promise<boolean> {
|
||||
const email = effectiveUserEmail(context).trim().toLowerCase();
|
||||
if (!email) return false;
|
||||
try {
|
||||
const emails = await emailsForType(context, requestTypeId);
|
||||
return emails.some((e) => e.trim().toLowerCase() === email);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Из переданных типов — те, чьи заявки текущему пользователю разрешено видеть целиком. */
|
||||
export async function accessibleAllRequestsTypes(
|
||||
context: WebPartContext,
|
||||
types: IRequestType[]
|
||||
): Promise<IRequestType[]> {
|
||||
const flags = await Promise.all(types.map((t) => hasRequestTypeAccess(context, t.id)));
|
||||
return types.filter((_, i) => flags[i]);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { getRequestTypesCached } from './approvalRoute.service';
|
||||
import { accessibleAllRequestsTypes } from './requestTypeAccess';
|
||||
|
||||
/**
|
||||
* pageUrl (formKey формы) → числовой RequestTypeId approval-route, только для
|
||||
* типов, чьи заявки текущему пользователю разрешено видеть целиком (см.
|
||||
* requestTypeAccess.ts). Один поход по всем типам разом — плитки и кнопка на
|
||||
* открытой форме (SubmitFeature) решают по нему, показывать ли переход в
|
||||
* «Все заявки», не дожидаясь резолва конкретного типа.
|
||||
*
|
||||
* undefined — ответ ещё не пришёл (плитки без кнопки, а не с ошибочно пустой).
|
||||
*/
|
||||
export function useAccessibleAllRequestsFormKeys(context: WebPartContext): Map<string, number> | undefined {
|
||||
const [map, setMap] = useState<Map<string, number> | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getRequestTypesCached(context)
|
||||
.then((types) => accessibleAllRequestsTypes(context, types))
|
||||
.then((accessible) => {
|
||||
if (cancelled) return;
|
||||
setMap(new Map(accessible.map((t) => [t.pageUrl, t.id])));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setMap(new Map());
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [context]);
|
||||
|
||||
return map;
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export type PortalMode = 'submit' | 'approve' | 'create' | 'edit';
|
||||
* «Возвращена на доработку»); в отличие от первых двух смотрит
|
||||
* на список данных по автору, а не на строки маршрута.
|
||||
*/
|
||||
export type NavSection = 'new' | 'approvals' | 'execution' | 'rework' | 'own' | 'search' | 'reports';
|
||||
export type NavSection = 'new' | 'approvals' | 'execution' | 'rework' | 'own' | 'search' | 'reports' | 'allRequests';
|
||||
|
||||
/**
|
||||
* Подвкладка раздела «Мои заявки». 'created' — заявки, где пользователь автор
|
||||
@@ -90,6 +90,12 @@ export interface IRoute {
|
||||
ownTab?: OwnTab;
|
||||
/** Фильтр по статусу заявки; задан только при nav='own' и ownTab='created'. */
|
||||
ownStatus?: string;
|
||||
/**
|
||||
* Выбранный тип заявки в разделе «Все заявки» (nav='allRequests') — числовой
|
||||
* RequestTypeId approval-route. Не задан — раздел сам выберет тип (первый
|
||||
* доступный текущему пользователю, см. AllRequestsFeature).
|
||||
*/
|
||||
allTypeId?: number;
|
||||
/** Номер страницы таблицы (1-based). 1 — значение по умолчанию, в адрес не пишется. */
|
||||
page?: number;
|
||||
/** Текст поиска по таблице. Пустой — значение по умолчанию, в адрес не пишется. */
|
||||
@@ -283,6 +289,7 @@ function readStoredNav(): NavSection {
|
||||
raw === 'own' ||
|
||||
raw === 'search' ||
|
||||
raw === 'reports' ||
|
||||
raw === 'allRequests' ||
|
||||
raw === 'new'
|
||||
? raw
|
||||
: 'new';
|
||||
@@ -291,6 +298,37 @@ function readStoredNav(): NavSection {
|
||||
}
|
||||
}
|
||||
|
||||
// Выбранный тип заявки в разделе «Все заявки» — тем же приёмом, что и
|
||||
// подвкладка/фильтр «Моих заявок» (OWN_TAB_STORAGE_KEY выше): переживает
|
||||
// перезагрузку страницы и новый визит, но не более того. Без этого переход в
|
||||
// другой раздел и обратно (клик по пункту меню, без rpalltype в адресе)
|
||||
// каждый раз сбрасывал бы выбор на первый доступный тип.
|
||||
const ALL_TYPE_STORAGE_KEY = 'rp-all-type';
|
||||
|
||||
function readStoredAllTypeId(): number | undefined {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(ALL_TYPE_STORAGE_KEY);
|
||||
const id = Number(raw);
|
||||
return raw && Number.isInteger(id) && id > 0 ? id : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Запомненный тип «Все заявки» — как и storedOwnTab, нужен там, где маршрут СТРОИТСЯ. */
|
||||
export function storedAllTypeId(): number | undefined {
|
||||
return readStoredAllTypeId();
|
||||
}
|
||||
|
||||
/** Запомнить тип «Все заявки» — вызывать только при явном выборе пользователя. */
|
||||
export function persistAllTypeId(typeId: number): void {
|
||||
try {
|
||||
window.localStorage.setItem(ALL_TYPE_STORAGE_KEY, String(typeId));
|
||||
} catch {
|
||||
// localStorage недоступен (приватный режим и т.п.) — просто не сохраняем.
|
||||
}
|
||||
}
|
||||
|
||||
/** Запомнить раздел бокового меню — вызывать только при явном выборе пользователя. */
|
||||
export function persistNav(nav: NavSection): void {
|
||||
try {
|
||||
@@ -308,6 +346,7 @@ function parseNav(raw: string | undefined): NavSection {
|
||||
raw === 'own' ||
|
||||
raw === 'search' ||
|
||||
raw === 'reports' ||
|
||||
raw === 'allRequests' ||
|
||||
raw === 'new'
|
||||
) {
|
||||
return raw;
|
||||
@@ -347,6 +386,18 @@ export function parseRoute(search: string = window.location.search): IRoute {
|
||||
const ownTab = nav === 'own' ? parseOwnTab(params.get('rptab') ?? undefined) : undefined;
|
||||
const ownStatus = ownTab === 'created' ? params.get('rpstatus') ?? readStoredOwnStatus() : undefined;
|
||||
|
||||
// Тип заявки в разделе «Все заявки» — из адреса, иначе запомненный (та же
|
||||
// логика, что у ownTab/ownStatus выше). Если ни того, ни другого нет (первый
|
||||
// визит) — undefined, и раздел сам выберет первый доступный тип (см.
|
||||
// AllRequestsFeature), а как только он выберет — маршрут и localStorage
|
||||
// подхватят выбор (см. gotoAllRequestsType в RequestPortal.tsx).
|
||||
const allTypeIdParam = nav === 'allRequests' ? params.get('rpalltype') : null;
|
||||
const allTypeIdFromUrl = allTypeIdParam ? Number(allTypeIdParam) : NaN;
|
||||
const allTypeId =
|
||||
nav === 'allRequests'
|
||||
? (Number.isInteger(allTypeIdFromUrl) && allTypeIdFromUrl > 0 ? allTypeIdFromUrl : readStoredAllTypeId())
|
||||
: undefined;
|
||||
|
||||
// Страница: из адреса, иначе — запомненная для ЭТОЙ таблицы. Мусор в
|
||||
// параметре (0, минус, буквы) трактуем как первую страницу, а не как ошибку:
|
||||
// ссылка приходит извне, ронять из-за неё раздел незачем.
|
||||
@@ -362,11 +413,11 @@ export function parseRoute(search: string = window.location.search): IRoute {
|
||||
// страницей: закрыв панель, пользователь должен вернуться ровно в тот же
|
||||
// список и на то же место в нём.
|
||||
if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) {
|
||||
return { mode: 'approve', nav, formKey, itemId, listId, ownTab, ownStatus, page, query };
|
||||
return { mode: 'approve', nav, formKey, itemId, listId, ownTab, ownStatus, allTypeId, page, query };
|
||||
}
|
||||
// Редактирование собственной незавершённой заявки — та же панель, но с формой.
|
||||
if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) {
|
||||
return { mode: 'edit', nav, formKey, itemId, listId, ownTab, ownStatus, page, query };
|
||||
return { mode: 'edit', nav, formKey, itemId, listId, ownTab, ownStatus, allTypeId, page, query };
|
||||
}
|
||||
// Создание производной заявки с предзаполнением из исходной.
|
||||
if (formKey && sourceId && !isNaN(sourceId)) {
|
||||
@@ -383,7 +434,7 @@ export function parseRoute(search: string = window.location.search): IRoute {
|
||||
if (formKey && mode !== 'approve' && mode !== 'edit') {
|
||||
return { mode: 'create', nav: 'new', formKey, positionId, nomenclatureId, storeName };
|
||||
}
|
||||
return { mode: 'submit', nav, ownTab, ownStatus, page, query };
|
||||
return { mode: 'submit', nav, ownTab, ownStatus, allTypeId, page, query };
|
||||
}
|
||||
|
||||
// Собрать query-строку маршрута, сохранив посторонние параметры страницы.
|
||||
@@ -399,6 +450,7 @@ function buildQuery(route: IRoute): string {
|
||||
params.delete('rpstatus');
|
||||
params.delete('rppage');
|
||||
params.delete('rpq');
|
||||
params.delete('rpalltype');
|
||||
|
||||
if (route.nav !== 'new') {
|
||||
params.set('rpnav', route.nav);
|
||||
@@ -410,6 +462,9 @@ function buildQuery(route: IRoute): string {
|
||||
if (ownTab !== 'created') params.set('rptab', ownTab);
|
||||
else if (route.ownStatus) params.set('rpstatus', route.ownStatus);
|
||||
}
|
||||
if (route.nav === 'allRequests' && route.allTypeId) {
|
||||
params.set('rpalltype', String(route.allTypeId));
|
||||
}
|
||||
// Первая страница — тоже значение по умолчанию (см. выше про чистую ссылку).
|
||||
if (route.page && route.page > 1) params.set('rppage', String(route.page));
|
||||
if (route.query) params.set('rpq', route.query);
|
||||
|
||||
Reference in New Issue
Block a user