Добавить раздел «Все заявки» для пользователей с доступом к типу

Новый раздел бокового меню показывает заявки выбранного типа целиком
(все авторы, а не только свои) — виден только тем, кому в админке
approval-route выдан RequestTypeAccess хотя бы на один тип. На форме
подачи заявки появляется кнопка «Все заявки этого типа», если доступ
выдан именно на открытый тип.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-09-25 14:46:12 +05:00
parent d539cdbfd2
commit fac11f3b6b
12 changed files with 438 additions and 30 deletions
@@ -34,11 +34,13 @@ import EditFeature from '../features/edit/EditFeature';
import { ListView, RequestsTableView } from '../features/myRequests/RequestsTableView'; import { ListView, RequestsTableView } from '../features/myRequests/RequestsTableView';
import { OwnTabs } from '../features/myRequests/OwnTabs'; import { OwnTabs } from '../features/myRequests/OwnTabs';
import { SearchView } from '../features/myRequests/SearchView'; 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 { IMyRequest } from '../features/myRequests/models/IMyRequest';
import { isEditableStatus } from '../shared/approval'; import { isEditableStatus } from '../shared/approval';
import { hasReportsAccess } from '../shared/reports/reportsAccess'; import { hasReportsAccess } from '../shared/reports/reportsAccess';
import { ReportsFeature } from '../features/reports/ReportsFeature'; import { ReportsFeature } from '../features/reports/ReportsFeature';
import { AllRequestsFeature } from '../features/myRequests/AllRequestsFeature';
import { accessibleAllRequestsTypes } from '../shared/approval';
// Оболочка портала: тёмное боковое меню (разделы) + основная область. // Оболочка портала: тёмное боковое меню (разделы) + основная область.
// Логики фич тут нет — файл общий, меняется редко. // Логики фич тут нет — файл общий, меняется редко.
@@ -102,6 +104,33 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
} }
}, [route.nav, canSeeReports]); }, [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) — сама форма // Уникальный код заявки для заголовка Drawer (approve/edit) — сама форма
// узнаёт его только после загрузки (см. onCodeLoaded в ApproveFeature/ // узнаёт его только после загрузки (см. onCodeLoaded в ApproveFeature/
// EditFeature), поэтому не берём route.itemId (это RowID в SharePoint, а не // EditFeature), поэтому не берём route.itemId (это RowID в SharePoint, а не
@@ -224,6 +253,24 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
}); });
}; };
// Переход в «Все заявки» — либо с бокового меню (typeId не задан, раздел сам
// выберет тип), либо с кнопки формы (typeId уже известен, см.
// gotoAllRequestsForType и SubmitFeature.onOpenAllRequests).
const gotoAllRequestsType = (typeId: number): void => {
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 setOwnStatus = (ownStatus: string): void => {
const normalized = ownStatus || undefined; const normalized = ownStatus || undefined;
persistOwnStatus(normalized); persistOwnStatus(normalized);
@@ -281,6 +328,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
listId: request.listId, listId: request.listId,
ownTab: route.ownTab, ownTab: route.ownTab,
ownStatus: route.ownStatus, ownStatus: route.ownStatus,
allTypeId: route.allTypeId,
// Страница и запрос — часть того же списка под панелью: без них ссылка на // Страница и запрос — часть того же списка под панелью: без них ссылка на
// открытую заявку вела бы на первую страницу без поиска. // открытую заявку вела бы на первую страницу без поиска.
page: route.page, page: route.page,
@@ -295,6 +343,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
nav: route.nav, nav: route.nav,
ownTab: route.ownTab, ownTab: route.ownTab,
ownStatus: route.ownStatus, ownStatus: route.ownStatus,
allTypeId: route.allTypeId,
page: route.page, page: route.page,
query: route.query, query: route.query,
}); });
@@ -324,6 +373,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
{ id: 'rework', title: 'Доработка', icon: 'edit', count: navCounts?.rework }, { id: 'rework', title: 'Доработка', icon: 'edit', count: navCounts?.rework },
{ id: 'own', title: 'Мои заявки', icon: 'clipboard' }, { id: 'own', title: 'Мои заявки', icon: 'clipboard' },
{ id: 'search', title: 'Поиск', icon: 'search' }, { id: 'search', title: 'Поиск', icon: 'search' },
...(canSeeAllRequests ? [{ id: 'allRequests', title: 'Все заявки', icon: 'people' } as ISidebarItem] : []),
...(canSeeReports ? [{ id: 'reports', title: 'Отчёты', icon: 'chart' } as ISidebarItem] : []), ...(canSeeReports ? [{ id: 'reports', title: 'Отчёты', icon: 'chart' } as ISidebarItem] : []),
{ id: 'instructions', title: 'Инструкция пользователя', icon: 'info' }, { id: 'instructions', title: 'Инструкция пользователя', icon: 'info' },
]; ];
@@ -413,6 +463,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
: undefined : undefined
} }
onFormSelected={(formKey) => navigate({ mode: 'create', nav: 'new', formKey })} onFormSelected={(formKey) => navigate({ mode: 'create', nav: 'new', formKey })}
onOpenAllRequests={gotoAllRequestsForType}
/> />
</div> </div>
@@ -433,6 +484,19 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
{route.nav === 'reports' && canSeeReports && <ReportsFeature context={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 && ( {LIST_VIEWS[route.nav] !== undefined && (
<RequestsTableView <RequestsTableView
context={context} 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 { import {
ApprovalQueueKind, ApprovalQueueKind,
filterPendingByQuery, filterPendingByQuery,
getAllRequestsForType,
getApprovalQueue, getApprovalQueue,
getApprovalQueueIndex, getApprovalQueueIndex,
getLinkedTypes, getLinkedTypes,
@@ -50,7 +51,8 @@ export type ListView =
| 'own:created' | 'own:created'
| 'own:approved' | 'own:approved'
| 'own:rejected' | 'own:rejected'
| 'own:executed'; | 'own:executed'
| 'all';
// Представления, которые строятся на строках маршрута пользователя, и их очередь // Представления, которые строятся на строках маршрута пользователя, и их очередь
// в сервисе. Всё, чего здесь нет ('rework'/'own:created'), читается из списка // в сервисе. Всё, чего здесь нет ('rework'/'own:created'), читается из списка
@@ -63,6 +65,9 @@ const QUEUE_BY_VIEW: { [key: string]: ApprovalQueueKind } = {
'own:executed': 'executed', 'own:executed': 'executed',
}; };
// 'all' — заголовок и текст пустоты зависят от выбранного типа заявки
// (см. allRequestsType и heading/emptyText ниже), здесь только запасной
// вариант на случай, пока тип ещё не выбран/не загрузился.
const HEADINGS: Record<ListView, string> = { const HEADINGS: Record<ListView, string> = {
approvals: 'Ждут вашего согласования', approvals: 'Ждут вашего согласования',
execution: 'Ждут вашего исполнения', execution: 'Ждут вашего исполнения',
@@ -71,6 +76,7 @@ const HEADINGS: Record<ListView, string> = {
'own:approved': 'Согласованные мной', 'own:approved': 'Согласованные мной',
'own:rejected': 'Отклонённые мной', 'own:rejected': 'Отклонённые мной',
'own:executed': 'Выполненные мной', 'own:executed': 'Выполненные мной',
all: 'Все заявки',
}; };
const EMPTY_TEXTS: Record<ListView, string> = { const EMPTY_TEXTS: Record<ListView, string> = {
@@ -81,6 +87,7 @@ const EMPTY_TEXTS: Record<ListView, string> = {
'own:approved': 'Вы ещё ничего не согласовали.', 'own:approved': 'Вы ещё ничего не согласовали.',
'own:rejected': 'Вы ещё ничего не отклоняли.', 'own:rejected': 'Вы ещё ничего не отклоняли.',
'own:executed': 'Вы ещё ничего не выполнили.', '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 === 'approvals' || view === 'execution') return { author: true, status: false };
if (view === 'rework') return { author: false, status: false, commentLabel }; if (view === 'rework') return { author: false, status: false, commentLabel };
if (view === 'own:created') return { author: false, status: !statusFilter, 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; onQueryChange: (query: string) => void;
/** Клик по строке — открыть заявку (в выезжающей панели). */ /** Клик по строке — открыть заявку (в выезжающей панели). */
onOpen: (request: IMyRequest) => void; onOpen: (request: IMyRequest) => void;
/** Только при view='all' — тип заявки, чьи заявки показываются целиком (без фильтра по автору). */
allRequestsType?: IRequestType;
} }
function formatDate(iso: string): string { function formatDate(iso: string): string {
@@ -330,6 +341,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
query, query,
onQueryChange, onQueryChange,
onOpen, onOpen,
allRequestsType,
}) => { }) => {
const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined); const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined); const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
@@ -361,7 +373,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
// по чужому totalRows и сбивал страницу на первую — из-за чего запомненная // по чужому totalRows и сбивал страницу на первую — из-за чего запомненная
// страница затиралась при каждом переходе по левому меню. // страница затиралась при каждом переходе по левому меню.
// Страница в ключ НЕ входит: totalRows один на всю таблицу, а не на страницу. // Страница в ключ НЕ входит: totalRows один на всю таблицу, а не на страницу.
const dataKey = `${view}|${statusFilter}|${debouncedQuery.trim()}`; const dataKey = `${view}|${statusFilter}|${debouncedQuery.trim()}|${allRequestsType?.id ?? ''}`;
const [loadedKey, setLoadedKey] = React.useState<string>(''); const [loadedKey, setLoadedKey] = React.useState<string>('');
// Страницу при смене запроса здесь НЕ сбрасываем: и запрос, и страница живут // Страницу при смене запроса здесь НЕ сбрасываем: и запрос, и страница живут
@@ -384,7 +396,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
React.useEffect(() => { React.useEffect(() => {
if (types === undefined) return; if (types === undefined) return;
let cancelled = false; 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); : 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]; const queue = QUEUE_BY_VIEW[view];
if (q.length === 0) { if (q.length === 0) {
// Без поиска — страница уже нарезана и дочитана сервером. // Без поиска — страница уже нарезана и дочитана сервером.
@@ -524,7 +541,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
unsubscribe(); unsubscribe();
window.clearInterval(timer); window.clearInterval(timer);
}; };
}, [context, types, view, statusFilter, debouncedQuery, page, dataKey]); }, [context, types, view, statusFilter, debouncedQuery, page, dataKey, allRequestsType]);
// Все ветки отдают уже нарезанную страницу — просто показываем как есть. // Все ветки отдают уже нарезанную страницу — просто показываем как есть.
const q = debouncedQuery.trim(); const q = debouncedQuery.trim();
@@ -561,7 +578,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
<section className={styles.view}> <section className={styles.view}>
<div className={styles.head}> <div className={styles.head}>
<h2 className={styles.heading}> <h2 className={styles.heading}>
{HEADINGS[view]} {view === 'all' && allRequestsType ? `Все заявки: ${allRequestsType.name}` : HEADINGS[view]}
{rows && <span className={styles.count}>{total}</span>} {rows && <span className={styles.count}>{total}</span>}
</h2> </h2>
{view === 'own:created' && onStatusFilterChange && ( {view === 'own:created' && onStatusFilterChange && (
@@ -39,8 +39,14 @@ export interface IMyRequest {
siteUrl: string; siteUrl: string;
/** Ключ формы (rptype в URL режима согласования) — поле pageUrl из approval-route API. */ /** Ключ формы (rptype в URL режима согласования) — поле pageUrl из approval-route API. */
formKey: string; formKey: string;
/** 'own' — заявка, созданная текущим пользователем; 'approval' — ждёт его решения. */ /**
kind: 'own' | 'approval'; * 'own' — заявка, созданная текущим пользователем; 'approval' — ждёт его
* решения; 'other' — раздел «Все заявки» (доступ настроен в админке
* approval-route): заявка чужая и открывается только на просмотр, как и
* 'approval' (см. openRequest в RequestPortal.tsx — по kind !== 'own' решение
* то же самое).
*/
kind: 'own' | 'approval' | 'other';
/** /**
* Статус СВОЕЙ строки маршрута (не заявки) — «На согласовании», «Согласовано» * Статус СВОЕЙ строки маршрута (не заявки) — «На согласовании», «Согласовано»
* и т.п. Заполняется только у kind='approval'. Нужен, чтобы разнести заявки * и т.п. Заполняется только у kind='approval'. Нужен, чтобы разнести заявки
@@ -1140,7 +1140,7 @@ interface ISearchEntry {
* компромиссом — searchOwnRequests и раньше не подмешивал REST-подстраховку * компромиссом — searchOwnRequests и раньше не подмешивал REST-подстраховку
* (в отличие от getOwnRequests, где её явно добавляет getFreshOwnRowsForType). * (в отличие от getOwnRequests, где её явно добавляет getFreshOwnRowsForType).
*/ */
function toMyRequestFromSearch(entry: ISearchEntry): IMyRequest { function toMyRequestFromSearch(entry: ISearchEntry, kind: 'own' | 'other' = 'own'): IMyRequest {
const r = entry.raw; const r = entry.raw;
const author = parseAuthorOwsUser(r[MP_AUTHOR]); const author = parseAuthorOwsUser(r[MP_AUTHOR]);
return { return {
@@ -1157,7 +1157,7 @@ function toMyRequestFromSearch(entry: ISearchEntry): IMyRequest {
requestTypeName: entry.type.name, requestTypeName: entry.type.name,
section: entry.type.section, section: entry.type.section,
formKey: entry.type.pageUrl, formKey: entry.type.pageUrl,
kind: 'own', kind,
listId: entry.target.listId, listId: entry.target.listId,
siteUrl: entry.target.siteUrl, siteUrl: entry.target.siteUrl,
}; };
@@ -1370,7 +1370,44 @@ export async function searchOwnRequests(
const totalRows = results.TotalRows || 0; const totalRows = results.TotalRows || 0;
// Поля — прямо из ответа индекса (см. toMyRequestFromSearch), REST-дочитывания // Поля — прямо из ответа индекса (см. 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.
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 results = await traced('Search: все заявки типа', () =>
sp.search({
Querytext: kql,
RowLimit: PAGE_SIZE,
StartRow: (page - 1) * PAGE_SIZE,
SelectProperties: OWN_SELECT_PROPERTIES,
TrimDuplicates: false,
SortList: [{ Property: 'Created', Direction: 1 }],
})
);
const totalRows = results.TotalRows || 0;
// kind: 'other' — заявка не обязательно текущего пользователя, открывается
// только на просмотр (см. IMyRequest.kind, openRequest в RequestPortal.tsx).
const rows = hitsToEntries(results, [type]).map((e) => toMyRequestFromSearch(e, 'other'));
return { rows, totalRows }; return { rows, totalRows };
} }
@@ -1446,7 +1483,7 @@ async function ownRequestsByStatus(
SortList: [{ Property: 'Created', Direction: 1 }], SortList: [{ Property: 'Created', Direction: 1 }],
}) })
).then((results) => ({ ).then((results) => ({
rows: hitsToEntries(results, types).map(toMyRequestFromSearch), rows: hitsToEntries(results, types).map((e) => toMyRequestFromSearch(e)),
totalRows: results.TotalRows || 0, totalRows: results.TotalRows || 0,
})); }));
@@ -1672,7 +1709,7 @@ export async function searchOwnAll(
traceSearchHits('Свои заявки', kql, types, results, listAbsUrl); traceSearchHits('Свои заявки', kql, types, results, listAbsUrl);
const entries = hitsToEntries(results, types); const entries = hitsToEntries(results, types);
const rows = entries.map(toMyRequestFromSearch); const rows = entries.map((e) => toMyRequestFromSearch(e));
traceResultRows('Свои заявки', types, entries, rows); traceResultRows('Свои заявки', types, entries, rows);
return rows; return rows;
@@ -49,3 +49,29 @@
font-size: $fs-sm; font-size: $fs-sm;
text-align: center; 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 { emitRequestsChanged } from '../../shared/requestEvents';
import { confirmDiscardFormChanges, markFormClean } from '../../shared/unsavedChanges'; import { confirmDiscardFormChanges, markFormClean } from '../../shared/unsavedChanges';
import { useIsTester } from '../../shared/useIsTester'; import { useIsTester } from '../../shared/useIsTester';
import { getRequestTypeIdByPageUrl, hasRequestTypeAccess } from '../../shared/approval';
export interface ISubmitFeatureProps { export interface ISubmitFeatureProps {
context: WebPartContext; context: WebPartContext;
@@ -25,6 +26,12 @@ export interface ISubmitFeatureProps {
/** Пользователь выбрал плитку — сообщаем наверх, чтобы адрес страницы стал /** Пользователь выбрал плитку — сообщаем наверх, чтобы адрес страницы стал
* ссылкой на эту пустую форму (см. newRequestLinkUrl в shared/routing). */ * ссылкой на эту пустую форму (см. newRequestLinkUrl в shared/routing). */
onFormSelected?: (formKey: string) => void; onFormSelected?: (formKey: string) => void;
/**
* Кнопка «Все заявки этого типа» на форме — только когда в админке
* approval-route текущему пользователю выдан доступ к этому типу (см.
* hasRequestTypeAccess ниже). Ведёт в раздел «Все заявки» (nav='allRequests').
*/
onOpenAllRequests?: (requestTypeId: number) => void;
} }
// Фича «Подать заявку»: плитки → беспоук-форма (категории/вкладки — в RequestPortal). // Фича «Подать заявку»: плитки → беспоук-форма (категории/вкладки — в RequestPortal).
@@ -35,6 +42,7 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({
initialForm, initialForm,
editForm, editForm,
onFormSelected, onFormSelected,
onOpenAllRequests,
}) => { }) => {
// «Создать на основе» и «редактировать» оба сразу открывают нужную форму, минуя плитки. // «Создать на основе» и «редактировать» оба сразу открывают нужную форму, минуя плитки.
const preOpen = initialForm ?? editForm; const preOpen = initialForm ?? editForm;
@@ -60,6 +68,34 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({
const requests = getRequestsByCategory(activeCategoryId); const requests = getRequestsByCategory(activeCategoryId);
const isTester = useIsTester(); const isTester = useIsTester();
// Кнопка «Все заявки этого типа» — только на форме открытого типа, и только
// если текущему пользователю в админке approval-route выдан доступ к нему
// (см. shared/approval/requestTypeAccess.ts). requestType.id формы совпадает
// с pageUrl approval-route по соглашению (см. useRequestTypeId).
const [allRequestsTypeId, setAllRequestsTypeId] = useState<number | undefined>(undefined);
const [canOpenAllRequests, setCanOpenAllRequests] = useState<boolean>(false);
useEffect(() => {
let cancelled = false;
setCanOpenAllRequests(false);
setAllRequestsTypeId(undefined);
if (!selectedRequest) return;
getRequestTypeIdByPageUrl(context, selectedRequest.id)
.then((id) => {
if (cancelled) return undefined;
setAllRequestsTypeId(id);
return hasRequestTypeAccess(context, id);
})
.then((allowed) => {
if (!cancelled && allowed) setCanOpenAllRequests(true);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [context, selectedRequest]);
// Возврат к плиткам закрывает форму и теряет введённое (раздел остаётся // Возврат к плиткам закрывает форму и теряет введённое (раздел остаётся
// смонтированным, но состояние формы живёт в самом FormComponent). Спрашиваем, // смонтированным, но состояние формы живёт в самом FormComponent). Спрашиваем,
// если правки есть и они не сохранены; после согласия флаг снимаем — терять // если правки есть и они не сохранены; после согласия флаг снимаем — терять
@@ -104,19 +140,33 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({
// Ленивый рендер формы: её код подтягивается только сейчас, при открытии. // Ленивый рендер формы: её код подтягивается только сейчас, при открытии.
return ( return (
<React.Suspense fallback={<div className={styles.loading}>Загрузка формы…</div>}> <>
<FormComponent {canOpenAllRequests && allRequestsTypeId && onOpenAllRequests && (
requestType={request} <div className={styles.formToolbar}>
context={context} <button
onCancel={handleBack} type="button"
onSaved={emitRequestsChanged} className={styles.allRequestsButton}
sourceId={sourceId} onClick={() => onOpenAllRequests(allRequestsTypeId)}
editId={editId} >
positionId={external?.positionId} <Icon name="people" size="sm" />
nomenclatureId={external?.nomenclatureId} <span>Все заявки этого типа</span>
storeName={external?.storeName} </button>
/> </div>
</React.Suspense> )}
<React.Suspense fallback={<div className={styles.loading}>Загрузка формы…</div>}>
<FormComponent
requestType={request}
context={context}
onCancel={handleBack}
onSaved={emitRequestsChanged}
sourceId={sourceId}
editId={editId}
positionId={external?.positionId}
nomenclatureId={external?.nomenclatureId}
storeName={external?.storeName}
/>
</React.Suspense>
</>
); );
}; };
@@ -147,6 +147,28 @@ export async function getRequestTypeIdByPageUrl(context: WebPartContext, pageUrl
return found.id; 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 — планируемый маршрут для типа заявки и // POST /api/template-routes/resolve — планируемый маршрут для типа заявки и
// пользователя. Бэкенд сам подставляет исполнителей шага (executorEmail/position) // пользователя. Бэкенд сам подставляет исполнителей шага (executorEmail/position)
// по ролям исходя из userEmail. Параметр status выбирает ФАЗУ маршрута: // по ролям исходя из userEmail. Параметр status выбирает ФАЗУ маршрута:
@@ -31,8 +31,10 @@ export {
getRequestTypeIdByPageUrl, getRequestTypeIdByPageUrl,
getListPartitions, getListPartitions,
getCurrentListPartition, getCurrentListPartition,
getRequestTypeAccessEmails,
resolveRoute resolveRoute
} from './approvalRoute.service'; } from './approvalRoute.service';
export { hasRequestTypeAccess, accessibleAllRequestsTypes } from './requestTypeAccess';
export { export {
initListPartitions, initListPartitions,
listPartitionsLoaded, 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]);
}
+23 -4
View File
@@ -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' — заявки, где пользователь автор * Подвкладка раздела «Мои заявки». 'created' — заявки, где пользователь автор
@@ -90,6 +90,12 @@ export interface IRoute {
ownTab?: OwnTab; ownTab?: OwnTab;
/** Фильтр по статусу заявки; задан только при nav='own' и ownTab='created'. */ /** Фильтр по статусу заявки; задан только при nav='own' и ownTab='created'. */
ownStatus?: string; ownStatus?: string;
/**
* Выбранный тип заявки в разделе «Все заявки» (nav='allRequests') — числовой
* RequestTypeId approval-route. Не задан — раздел сам выберет тип (первый
* доступный текущему пользователю, см. AllRequestsFeature).
*/
allTypeId?: number;
/** Номер страницы таблицы (1-based). 1 — значение по умолчанию, в адрес не пишется. */ /** Номер страницы таблицы (1-based). 1 — значение по умолчанию, в адрес не пишется. */
page?: number; page?: number;
/** Текст поиска по таблице. Пустой — значение по умолчанию, в адрес не пишется. */ /** Текст поиска по таблице. Пустой — значение по умолчанию, в адрес не пишется. */
@@ -283,6 +289,7 @@ function readStoredNav(): NavSection {
raw === 'own' || raw === 'own' ||
raw === 'search' || raw === 'search' ||
raw === 'reports' || raw === 'reports' ||
raw === 'allRequests' ||
raw === 'new' raw === 'new'
? raw ? raw
: 'new'; : 'new';
@@ -308,6 +315,7 @@ function parseNav(raw: string | undefined): NavSection {
raw === 'own' || raw === 'own' ||
raw === 'search' || raw === 'search' ||
raw === 'reports' || raw === 'reports' ||
raw === 'allRequests' ||
raw === 'new' raw === 'new'
) { ) {
return raw; return raw;
@@ -347,6 +355,13 @@ export function parseRoute(search: string = window.location.search): IRoute {
const ownTab = nav === 'own' ? parseOwnTab(params.get('rptab') ?? undefined) : undefined; const ownTab = nav === 'own' ? parseOwnTab(params.get('rptab') ?? undefined) : undefined;
const ownStatus = ownTab === 'created' ? params.get('rpstatus') ?? readStoredOwnStatus() : undefined; const ownStatus = ownTab === 'created' ? params.get('rpstatus') ?? readStoredOwnStatus() : undefined;
// Тип заявки в разделе «Все заявки» — только из адреса: в отличие от
// ownTab/ownStatus, здесь не читаем «запомненное» значение при его отсутствии
// (список доступных типов не известен без похода в API, поэтому выбор
// значения по умолчанию отдаём самому разделу, см. AllRequestsFeature).
const allTypeIdRaw = nav === 'allRequests' ? Number(params.get('rpalltype')) : NaN;
const allTypeId = Number.isInteger(allTypeIdRaw) && allTypeIdRaw > 0 ? allTypeIdRaw : undefined;
// Страница: из адреса, иначе — запомненная для ЭТОЙ таблицы. Мусор в // Страница: из адреса, иначе — запомненная для ЭТОЙ таблицы. Мусор в
// параметре (0, минус, буквы) трактуем как первую страницу, а не как ошибку: // параметре (0, минус, буквы) трактуем как первую страницу, а не как ошибку:
// ссылка приходит извне, ронять из-за неё раздел незачем. // ссылка приходит извне, ронять из-за неё раздел незачем.
@@ -362,11 +377,11 @@ export function parseRoute(search: string = window.location.search): IRoute {
// страницей: закрыв панель, пользователь должен вернуться ровно в тот же // страницей: закрыв панель, пользователь должен вернуться ровно в тот же
// список и на то же место в нём. // список и на то же место в нём.
if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) { 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)) { 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)) { if (formKey && sourceId && !isNaN(sourceId)) {
@@ -383,7 +398,7 @@ export function parseRoute(search: string = window.location.search): IRoute {
if (formKey && mode !== 'approve' && mode !== 'edit') { if (formKey && mode !== 'approve' && mode !== 'edit') {
return { mode: 'create', nav: 'new', formKey, positionId, nomenclatureId, storeName }; 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-строку маршрута, сохранив посторонние параметры страницы. // Собрать query-строку маршрута, сохранив посторонние параметры страницы.
@@ -399,6 +414,7 @@ function buildQuery(route: IRoute): string {
params.delete('rpstatus'); params.delete('rpstatus');
params.delete('rppage'); params.delete('rppage');
params.delete('rpq'); params.delete('rpq');
params.delete('rpalltype');
if (route.nav !== 'new') { if (route.nav !== 'new') {
params.set('rpnav', route.nav); params.set('rpnav', route.nav);
@@ -410,6 +426,9 @@ function buildQuery(route: IRoute): string {
if (ownTab !== 'created') params.set('rptab', ownTab); if (ownTab !== 'created') params.set('rptab', ownTab);
else if (route.ownStatus) params.set('rpstatus', route.ownStatus); 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.page && route.page > 1) params.set('rppage', String(route.page));
if (route.query) params.set('rpq', route.query); if (route.query) params.set('rpq', route.query);