скелет готов

This commit is contained in:
gorbenko.pavel
2026-08-10 13:48:16 +05:00
parent f6f4872a7c
commit 2ed5d5a7d3
10 changed files with 620 additions and 134 deletions
@@ -2,7 +2,7 @@ import * as React from 'react';
import styles from './RequestPortal.module.scss';
import type { IRequestPortalProps } from './IRequestPortalProps';
import { IconSprite } from '../shared/ui/Icon/IconSprite';
import { navigate, useRoute, NavSection } from '../shared/routing';
import { navigate, useRoute, NavSection, OwnTab } from '../shared/routing';
import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents';
import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges';
import { SideNav } from './SideNav/SideNav';
@@ -13,9 +13,10 @@ import { REQUEST_TYPES } from '../data/requests';
import SubmitFeature from '../features/submit/SubmitFeature';
import ApproveFeature from '../features/approve/ApproveFeature';
import EditFeature from '../features/edit/EditFeature';
import { RequestsTableView } from '../features/myRequests/RequestsTableView';
import { ListView, RequestsTableView } from '../features/myRequests/RequestsTableView';
import { OwnTabs } from '../features/myRequests/OwnTabs';
import { SearchView } from '../features/myRequests/SearchView';
import { getPendingApproval } from '../features/myRequests/myRequests.service';
import { getNavCounts, INavCounts } from '../features/myRequests/myRequests.service';
import { IMyRequest } from '../features/myRequests/models/IMyRequest';
import { isEditableStatus } from '../shared/approval';
@@ -26,6 +27,16 @@ import { isEditableStatus } from '../shared/approval';
// 'approvals'/'own' — таблицы заявок. Клик по строке таблицы открывает заявку
// в выезжающей панели ПОВЕРХ раздела (rpmode=approve&rptype=&rpid=), поэтому
// раздел под ней сохраняется; та же ссылка приходит из потока Power Automate.
// Разделы, которые показывают таблицу заявок, и соответствующее ей
// представление. У 'own' оно зависит ещё и от подвкладки (см. ниже), поэтому
// здесь стоит заглушка — важен сам факт наличия ключа.
const LIST_VIEWS: Partial<Record<NavSection, ListView>> = {
approvals: 'approvals',
execution: 'execution',
rework: 'rework',
own: 'own:created',
};
const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context }) => {
const rootClasses = [styles.requestPortal, hasTeamsContext ? styles.teams : '']
.filter(Boolean)
@@ -33,7 +44,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
const route = useRoute();
const [navCollapsed, setNavCollapsed] = React.useState<boolean>(false);
const [approvalsCount, setApprovalsCount] = React.useState<number | undefined>(undefined);
const [navCounts, setNavCounts] = React.useState<INavCounts | undefined>(undefined);
const [activeCategoryId, setActiveCategoryId] = React.useState<string>(() => {
if (route.mode === 'create' && route.formKey) {
@@ -43,22 +54,21 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
return DEFAULT_CATEGORY_ID;
});
// Счётчик на пункте «На согласовании». Обновляется по тому же событию, что и
// сами таблицы (создание заявки, решение согласующего), и на монтировании
// (в т.ч. на обновление страницы). Когда сама вкладка «На согласовании»
// открыта, RequestsTableView сообщает точное число через onMyTurnCountChange
// (ниже) — это перезаписывает значение отсюда, чтобы не было гонки между
// двумя независимыми запросами одних и тех же данных.
// Счётчики на пунктах-очередях («На согласовании», «На исполнении»,
// «Доработка»). Обновляются по тому же событию, что и сами таблицы (создание
// заявки, решение согласующего), и на монтировании (в т.ч. на обновление
// страницы). Все три приходят из одного вызова getNavCounts — он же следит,
// чтобы цифры не разошлись с содержимым вкладок.
React.useEffect(() => {
let cancelled = false;
const load = (): void => {
getPendingApproval(context)
.then((r) => {
if (!cancelled) setApprovalsCount(r.totalRows);
getNavCounts(context)
.then((c) => {
if (!cancelled) setNavCounts(c);
})
.catch(() => {
if (!cancelled) setApprovalsCount(undefined);
if (!cancelled) setNavCounts(undefined);
});
};
@@ -100,8 +110,28 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
// таблица на него подписана и перечитает данные фоном, не убирая с экрана
// текущие строки (см. RequestsTableView/SearchView).
if (nav === route.nav && route.mode === 'submit') emitRequestsChanged();
navigate({ mode: 'submit', nav });
// Возврат в «Мои заявки» открывает ту же подвкладку и с тем же фильтром,
// что были в прошлый раз, — иначе уход в другой раздел и обратно сбрасывал
// бы выбор пользователя.
navigate(
nav === 'own'
? { mode: 'submit', nav, ownTab: route.ownTab, ownStatus: route.ownStatus }
: { mode: 'submit', nav }
);
};
const gotoOwnTab = (ownTab: OwnTab): void =>
// Фильтр по статусу относится только к «Инициированным» — на других
// подвкладках отбор задаёт сама вкладка, поэтому фильтр туда не тащим.
navigate({
mode: 'submit',
nav: 'own',
ownTab,
ownStatus: ownTab === 'created' ? route.ownStatus : undefined,
});
const setOwnStatus = (ownStatus: string): void =>
navigate({ mode: 'submit', nav: 'own', ownTab: 'created', ownStatus: ownStatus || undefined });
// Клик по строке таблицы. СВОЯ заявка в незавершённом статусе (черновик,
// возвращённая, отклонённая) открывается на редактирование, всё остальное —
// на просмотр: чужую заявку править нельзя, а свою уже ушедшую по маршруту —
@@ -112,9 +142,14 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
nav: route.nav,
formKey: request.formKey,
itemId: request.id,
ownTab: route.ownTab,
ownStatus: route.ownStatus,
});
const closeDrawer = (): void => navigate({ mode: 'submit', nav: route.nav });
// Закрывая панель, возвращаемся ровно в тот список, из которого её открыли —
// включая подвкладку и фильтр «Моих заявок».
const closeDrawer = (): void =>
navigate({ mode: 'submit', nav: route.nav, ownTab: route.ownTab, ownStatus: route.ownStatus });
// Закрытие панели РЕДАКТИРОВАНИЯ: там открыта форма, и её содержимое живёт
// только в памяти — уходя без сохранения, пользователь теряет правки.
@@ -132,7 +167,11 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
<SideNav
activeId={route.nav}
onChange={goto}
approvalsCount={approvalsCount}
counts={{
approvals: navCounts?.approvals,
execution: navCounts?.execution,
rework: navCounts?.rework,
}}
collapsed={navCollapsed}
onToggleCollapsed={() => setNavCollapsed((v) => !v)}
/>
@@ -185,12 +224,21 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
/>
)}
{(route.nav === 'approvals' || route.nav === 'own') && (
{route.nav === 'own' && (
<OwnTabs activeId={route.ownTab ?? 'created'} onChange={gotoOwnTab} />
)}
{LIST_VIEWS[route.nav] !== undefined && (
<RequestsTableView
context={context}
kind={route.nav === 'approvals' ? 'approvals' : 'own'}
view={
route.nav === 'own'
? (`own:${route.ownTab ?? 'created'}` as ListView)
: (LIST_VIEWS[route.nav] as ListView)
}
statusFilter={route.ownStatus ?? ''}
onStatusFilterChange={setOwnStatus}
onOpen={openRequest}
onMyTurnCountChange={route.nav === 'approvals' ? setApprovalsCount : undefined}
/>
)}
</main>
@@ -10,9 +10,13 @@ interface INavItem {
icon: IconName;
}
// Порядок неслучаен: сверху действие (создать), затем три очереди — то, что
// ждёт пользователя прямо сейчас, — и только потом архив и поиск.
const ITEMS: INavItem[] = [
{ id: 'new', title: 'Новая заявка', icon: 'plus' },
{ id: 'approvals', title: 'На согласовании', icon: 'user-check' },
{ id: 'execution', title: 'На исполнении', icon: 'helmet' },
{ id: 'rework', title: 'Доработка', icon: 'edit' },
{ id: 'own', title: 'Мои заявки', icon: 'clipboard' },
{ id: 'search', title: 'Поиск', icon: 'search' },
];
@@ -20,8 +24,8 @@ const ITEMS: INavItem[] = [
interface ISideNavProps {
activeId: NavSection;
onChange: (id: NavSection) => void;
/** Счётчик у пункта «На согласовании» (сколько ждут решения). */
approvalsCount?: number;
/** Счётчики очередей: сколько заявок ждёт пользователя в каждом разделе. */
counts?: Partial<Record<NavSection, number>>;
collapsed: boolean;
onToggleCollapsed: () => void;
}
@@ -31,7 +35,7 @@ interface ISideNavProps {
export const SideNav: React.FC<ISideNavProps> = ({
activeId,
onChange,
approvalsCount,
counts,
collapsed,
onToggleCollapsed,
}) => {
@@ -58,7 +62,8 @@ export const SideNav: React.FC<ISideNavProps> = ({
{ITEMS.map((item) => {
const isActive = item.id === activeId;
const itemClasses = [styles.item, isActive ? styles.itemActive : ''].filter(Boolean).join(' ');
const showCount = item.id === 'approvals' && !!approvalsCount;
const count = counts?.[item.id];
const showCount = !!count;
return (
<button
@@ -73,7 +78,7 @@ export const SideNav: React.FC<ISideNavProps> = ({
<Icon name={item.icon} size="sm" />
</span>
{!collapsed && <span className={styles.itemTitle}>{item.title}</span>}
{showCount && <span className={styles.badge}>{approvalsCount}</span>}
{showCount && <span className={styles.badge}>{count}</span>}
</button>
);
})}