Files
request-portal/src/webparts/requestPortal/components/RequestPortal.tsx
T
gorbenko.pavel 70450aa66d
Tests / frontend-test (push) Successful in 1m25s
Переработка списков (#5)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/5
2026-08-11 04:23:05 +00:00

365 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
persistOwnTab,
persistOwnStatus,
persistPage,
persistQuery,
storedOwnStatus,
storedOwnTab,
storedPageFor,
storedQueryFor,
useRoute,
NavSection,
OwnTab,
} from '../shared/routing';
import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents';
import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges';
import { SideNav } from './SideNav/SideNav';
import { Drawer } from './Drawer/Drawer';
import { CategoryTabs } from './CategoryTabs/CategoryTabs';
import { CATEGORIES, DEFAULT_CATEGORY_ID } from '../data/categories';
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 { 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 { IMyRequest } from '../features/myRequests/models/IMyRequest';
import { isEditableStatus } from '../shared/approval';
// Оболочка портала: тёмное боковое меню (разделы) + основная область.
// Логики фич тут нет — файл общий, меняется редко.
//
// Разделы (rpnav): 'new' — подача заявки (вкладки категорий + плитки + форма),
// '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)
.join(' ');
const route = useRoute();
const [navCollapsed, setNavCollapsed] = React.useState<boolean>(false);
const [navCounts, setNavCounts] = React.useState<INavCounts | undefined>(undefined);
const [activeCategoryId, setActiveCategoryId] = React.useState<string>(() => {
if (route.mode === 'create' && route.formKey) {
const found = REQUEST_TYPES.filter((r) => r.id === route.formKey)[0];
if (found) return found.categoryId;
}
return DEFAULT_CATEGORY_ID;
});
// Счётчики на пунктах-очередях («На согласовании», «На исполнении»,
// «Доработка»). Обновляются по тому же событию, что и сами таблицы (создание
// заявки, решение согласующего), и на монтировании (в т.ч. на обновление
// страницы). Все три приходят из одного вызова getNavCounts — он же следит,
// чтобы цифры не разошлись с содержимым вкладок.
React.useEffect(() => {
let cancelled = false;
const load = (): void => {
getNavCounts(context)
.then((c) => {
if (!cancelled) setNavCounts(c);
})
.catch(() => {
if (!cancelled) setNavCounts(undefined);
});
};
load();
const unsubscribe = onRequestsChanged(load);
return () => {
cancelled = true;
unsubscribe();
};
}, [context]);
// Переход «создать на основе» (в т.ч. из открытой заявки в панели): переключаем
// вкладку категорий на категорию целевой формы, чтобы она совпала с открытой формой.
// Форма при этом пересоздаётся (см. key у SubmitFeature), поэтому прежние
// несохранённые правки к ней уже не относятся — снимаем флаг.
React.useEffect(() => {
if ((route.mode === 'create' || route.mode === 'edit') && route.formKey) {
const found = REQUEST_TYPES.filter((r) => r.id === route.formKey)[0];
if (found) setActiveCategoryId(found.categoryId);
markFormClean();
}
}, [route.mode, route.formKey]);
// Сохранение (в том числе черновика) шлёт rp-requests-changed — после него
// терять нечего, предупреждать при «создать на основе» больше не о чем.
React.useEffect(() => onRequestsChanged(markFormClean), []);
// Смена категории закрывает открытую форму (SubmitFeature сбрасывает выбор,
// когда категория перестаёт совпадать) — значит введённое пропадёт.
const changeCategory = (categoryId: string): void => {
if (!confirmDiscardFormChanges()) return;
markFormClean();
setActiveCategoryId(categoryId);
};
const goto = (nav: NavSection): void => {
// Клик по УЖЕ активному пункту меню не меняет маршрут, значит эффект
// загрузки в таблице не перезапустится сам. Шлём событие обновления —
// таблица на него подписана и перечитает данные фоном, не убирая с экрана
// текущие строки (см. RequestsTableView/SearchView).
if (nav === route.nav && route.mode === 'submit') emitRequestsChanged();
// Всё восстанавливаемое состояние раздела передаём ЯВНО, из запомненного.
// parseRoute подставил бы его и сам, но тогда оно осталось бы только в
// состоянии и не попало бы в адрес: ссылка на раздел вела бы на первую
// страницу без фильтра, а на экране была бы открыта другая.
//
// Брать route.ownTab/route.ownStatus здесь нельзя: при переходе ИЗ другого
// раздела они undefined (parseRoute заполняет их только при nav==='own'),
// и явная передача сбросила бы выбор пользователя.
if (nav === 'own') {
const ownTab = storedOwnTab();
navigate({
mode: 'submit',
nav,
ownTab,
ownStatus: storedOwnStatus(ownTab),
page: storedPageFor('own', ownTab),
query: storedQueryFor('own', ownTab) || undefined,
});
return;
}
navigate({
mode: 'submit',
nav,
page: storedPageFor(nav),
query: storedQueryFor(nav) || undefined,
});
};
const gotoOwnTab = (ownTab: OwnTab): void => {
// Фильтр и страница — из запомненного для ЭТОЙ подвкладки (см. goto выше о
// том, почему не из route). На вкладках с решениями фильтра нет по смыслу —
// storedOwnStatus сам вернёт undefined.
persistOwnTab(ownTab);
navigate({
mode: 'submit',
nav: 'own',
ownTab,
ownStatus: storedOwnStatus(ownTab),
page: storedPageFor('own', ownTab),
query: storedQueryFor('own', ownTab) || undefined,
});
};
const setOwnStatus = (ownStatus: string): void => {
const normalized = ownStatus || undefined;
persistOwnStatus(normalized);
// Смена фильтра — другой набор заявок, поэтому явно с первой страницы
// (и запоминаем именно её, иначе вернувшись сюда попали бы на старую).
persistPage({ mode: 'submit', nav: 'own', ownTab: 'created' }, 1);
// Текст поиска переносим ЯВНО: раздел тот же, поле на экране не очищается —
// значит и в адресе запрос должен остаться. Без этого rpq пропадал из
// ссылки при каждой смене фильтра (в самом состоянии он всё равно
// восстанавливался из запомненного, поэтому расхождение адреса с экраном
// было видно не сразу).
navigate({
mode: 'submit',
nav: 'own',
ownTab: 'created',
ownStatus: normalized,
page: 1,
query: route.query || undefined,
});
};
// Смена страницы сохраняет ВЕСЬ остальной маршрут (в т.ч. открытую заявку),
// меняется только номер: так «назад» возвращает на прежнюю страницу, а
// ссылку со страницей можно передать.
const setPage = (page: number): void => {
persistPage(route, page);
navigate({ ...route, page });
};
// Новый запрос — другой набор результатов, поэтому вместе с ним всегда первая
// страница (иначе можно приземлиться на несуществующую).
//
// replace, а не push: текст уходит сюда после КАЖДОЙ паузы в наборе, и с
// обычным переходом каждое слово становилось бы отдельным шагом «назад» —
// чтобы уйти со страницы, пришлось бы «отлистать» весь набранный запрос.
const setQuery = (query: string): void => {
persistQuery(route, query);
persistPage(route, 1);
navigate({ ...route, query: query || undefined, page: 1 }, { replace: true });
};
// Клик по строке таблицы. СВОЯ заявка в незавершённом статусе (черновик,
// возвращённая, отклонённая) открывается на редактирование, всё остальное —
// на просмотр: чужую заявку править нельзя, а свою уже ушедшую по маршруту —
// тем более (её статус в isEditableStatus не входит).
const openRequest = (request: IMyRequest): void =>
navigate({
mode: request.kind === 'own' && isEditableStatus(request.status) ? 'edit' : 'approve',
nav: route.nav,
formKey: request.formKey,
itemId: request.id,
ownTab: route.ownTab,
ownStatus: route.ownStatus,
// Страница и запрос — часть того же списка под панелью: без них ссылка на
// открытую заявку вела бы на первую страницу без поиска.
page: route.page,
query: route.query,
});
// Закрывая панель, возвращаемся ровно в тот список, из которого её открыли —
// включая подвкладку и фильтр «Моих заявок».
const closeDrawer = (): void =>
navigate({
mode: 'submit',
nav: route.nav,
ownTab: route.ownTab,
ownStatus: route.ownStatus,
page: route.page,
query: route.query,
});
// Закрытие панели РЕДАКТИРОВАНИЯ: там открыта форма, и её содержимое живёт
// только в памяти — уходя без сохранения, пользователь теряет правки.
const closeEditDrawer = (): void => {
if (!confirmDiscardFormChanges()) return;
markFormClean();
closeDrawer();
};
return (
<section className={rootClasses}>
<IconSprite />
<div className={styles.layout}>
<div className={styles.side}>
<SideNav
activeId={route.nav}
onChange={goto}
counts={{
approvals: navCounts?.approvals,
execution: navCounts?.execution,
rework: navCounts?.rework,
}}
collapsed={navCollapsed}
onToggleCollapsed={() => setNavCollapsed((v) => !v)}
/>
</div>
<main className={styles.main}>
{/* Раздел «Новая заявка» держим СМОНТИРОВАННЫМ и просто прячем стилем,
когда открыта другая вкладка. Иначе React размонтировал бы форму, и
всё введённое пропадало бы при переключении на «Мои заявки» и
обратно. Ключ завязан на «создать на основе»: обычные переключения
вкладок его не меняют (состояние живёт), а вот открытие новой
заявки на основе исходной пересоздаёт форму — SubmitFeature читает
initialForm только при монтировании. */}
<div
className={route.nav === 'new' ? undefined : styles.hiddenPane}
// Делегирование: любой пользовательский ввод внутри раздела помечает
// форму изменённой. Так не нужно инструментировать каждую форму по
// отдельности, и программное заполнение (предзаполнение «на основе»)
// сюда не попадает — React выставляет значения без этих событий.
onInput={markFormDirty}
onChange={markFormDirty}
>
<div className={styles.tabsWrap}>
<CategoryTabs
categories={CATEGORIES}
activeId={activeCategoryId}
onChange={changeCategory}
/>
</div>
<SubmitFeature
key={
route.mode === 'create' && route.formKey && route.sourceId
? `create-${route.formKey}-${route.sourceId}`
: 'blank'
}
context={context}
activeCategoryId={activeCategoryId}
initialForm={
route.mode === 'create' && route.formKey && route.sourceId
? { formKey: route.formKey, sourceId: route.sourceId }
: undefined
}
/>
</div>
{route.nav === 'search' && (
<SearchView
context={context}
page={route.page ?? 1}
onPageChange={setPage}
query={route.query ?? ''}
onQueryChange={setQuery}
onOpen={openRequest}
/>
)}
{route.nav === 'own' && (
<OwnTabs activeId={route.ownTab ?? 'created'} onChange={gotoOwnTab} />
)}
{LIST_VIEWS[route.nav] !== undefined && (
<RequestsTableView
context={context}
view={
route.nav === 'own'
? (`own:${route.ownTab ?? 'created'}` as ListView)
: (LIST_VIEWS[route.nav] as ListView)
}
statusFilter={route.ownStatus ?? ''}
onStatusFilterChange={setOwnStatus}
page={route.page ?? 1}
onPageChange={setPage}
query={route.query ?? ''}
onQueryChange={setQuery}
onOpen={openRequest}
/>
)}
</main>
</div>
{route.mode === 'approve' && route.formKey && route.itemId && (
<Drawer title={`Заявка №${route.itemId}`} onClose={closeDrawer}>
<ApproveFeature context={context} formKey={route.formKey} itemId={route.itemId} />
</Drawer>
)}
{/* Своя незавершённая заявка — та же панель, но с формой. Ввод здесь тоже
помечает состояние изменённым, поэтому обёртка ловит input/change. */}
{route.mode === 'edit' && route.formKey && route.itemId && (
<Drawer title={`Заявка №${route.itemId}`} onClose={closeEditDrawer}>
<div onInput={markFormDirty} onChange={markFormDirty}>
<EditFeature
context={context}
formKey={route.formKey}
itemId={route.itemId}
onClose={closeDrawer}
/>
</div>
</Drawer>
)}
</section>
);
};
export default RequestPortal;