Feature/request type access all requests (#22)
Tests / frontend-test (push) Successful in 1m24s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/22
This commit was merged in pull request #22.
This commit is contained in:
2026-09-25 11:48:09 +00:00
parent ebc74dc46d
commit 98be3980cb
15 changed files with 590 additions and 56 deletions
@@ -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,33 +31,45 @@ 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 (
<button
key={request.id}
type="button"
className={isLocked ? `${styles.tile} ${styles.tileDisabled}` : styles.tile}
disabled={isLocked}
onClick={() => onSelect(request)}
>
<span className={styles.tileIcon}>
<Icon name={request.icon} />
</span>
<span className={styles.tileBody}>
<span className={styles.tileTitle}>{request.title}</span>
{request.comingSoon ? (
<Badge variant="muted" size="sm">
В разработке
</Badge>
) : (
request.description && <span className={styles.tileDesc}>{request.description}</span>
)}
</span>
{!isLocked && (
<span className={styles.tileArrow}>
<Icon name="chevron-right" size="sm" />
<div key={request.id} className={styles.tileWrap}>
<button
type="button"
className={isLocked ? `${styles.tile} ${styles.tileDisabled}` : styles.tile}
disabled={isLocked}
onClick={() => onSelect(request)}
>
<span className={styles.tileIcon}>
<Icon name={request.icon} />
</span>
<span className={styles.tileBody}>
<span className={styles.tileTitle}>{request.title}</span>
{request.comingSoon ? (
<Badge variant="muted" size="sm">
В разработке
</Badge>
) : (
request.description && <span className={styles.tileDesc}>{request.description}</span>
)}
</span>
{!isLocked && (
<span className={styles.tileArrow}>
<Icon name="chevron-right" size="sm" />
</span>
)}
</button>
{allRequestsTypeId !== undefined && onOpenAllRequests && (
<button
type="button"
className={styles.allRequestsButton}
title="Все заявки этого типа"
onClick={() => onOpenAllRequests(allRequestsTypeId)}
>
<Icon name="people" size="sm" />
</button>
)}
</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}