4c851b4892
Tests / frontend-test (push) Successful in 1m19s
Если в Teams закреплена обычная страница SharePoint, SPFx не передаёт веб-части Teams-контекст, и объявление «открывайте в браузере» не показывалось. Теперь Teams определяется ещё по фрейму и User-Agent. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
575 lines
29 KiB
TypeScript
575 lines
29 KiB
TypeScript
import * as React from 'react';
|
||
import styles from './RequestPortal.module.scss';
|
||
import '@tnsu/ui-kit/styles.css';
|
||
import type { IRequestPortalProps } from './IRequestPortalProps';
|
||
import { IconSprite, Sidebar, SidebarToggle } from '@tnsu/ui-kit';
|
||
import type { ISidebarItem } from '@tnsu/ui-kit';
|
||
import { ConfirmHost } from '../shared/ui';
|
||
import {
|
||
navigate,
|
||
persistAllTypeId,
|
||
persistNav,
|
||
persistOwnTab,
|
||
persistOwnStatus,
|
||
persistPage,
|
||
persistQuery,
|
||
storedAllTypeId,
|
||
storedOwnStatus,
|
||
storedOwnTab,
|
||
storedPageFor,
|
||
storedQueryFor,
|
||
useRoute,
|
||
NavSection,
|
||
OwnTab,
|
||
} from '../shared/routing';
|
||
import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents';
|
||
import { USER_GUIDE_URL } from '../shared/config';
|
||
import { getImpersonatedEmail, getImpersonatedDisplayName } from '../shared/impersonation';
|
||
import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges';
|
||
import { Drawer } from './Drawer/Drawer';
|
||
import { CategoryTabs } from './CategoryTabs/CategoryTabs';
|
||
import { TeamsBrowserNotice } from './TeamsBrowserNotice/TeamsBrowserNotice';
|
||
import { isOpenedInTeams } from '../shared/hostEnvironment';
|
||
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 { 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';
|
||
|
||
// Оболочка портала: тёмное боковое меню (разделы) + основная область.
|
||
// Логики фич тут нет — файл общий, меняется редко.
|
||
//
|
||
// Разделы (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(' ');
|
||
|
||
// Читаем при рендере, а не через состояние: значение выставляет веб-часть
|
||
// (setImpersonatedEmail в render) до создания этого элемента, и меняется оно
|
||
// только вместе с новым render всей веб-части.
|
||
const impersonatedEmail = getImpersonatedEmail();
|
||
const impersonatedDisplayName = getImpersonatedDisplayName();
|
||
|
||
const route = useRoute();
|
||
const [navCollapsed, setNavCollapsed] = React.useState<boolean>(false);
|
||
const [mobileNavOpen, setMobileNavOpen] = React.useState<boolean>(false);
|
||
const [navCounts, setNavCounts] = React.useState<INavCounts | undefined>(undefined);
|
||
|
||
// Раздел «Отчёты» — группа «Отчеты по заявкам» + администраторы (см.
|
||
// reportsAccess.ts). undefined — ответ ещё не пришёл. Перезапрашиваем при
|
||
// смене подменённой личности.
|
||
const [canSeeReports, setCanSeeReports] = React.useState<boolean | undefined>(undefined);
|
||
React.useEffect(() => {
|
||
let cancelled = false;
|
||
setCanSeeReports(undefined);
|
||
hasReportsAccess(context)
|
||
.then((v) => {
|
||
if (!cancelled) setCanSeeReports(v);
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setCanSeeReports(false);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [context, impersonatedEmail]);
|
||
|
||
// Прямой переход по ссылке (?rpnav=reports) в обход бокового меню — тот же
|
||
// раздел, что скрыт из навигации, должен быть недоступен и так: без этого
|
||
// редиректа он открылся бы у любого, кто угадает или сохранит такую ссылку.
|
||
// Пока ответ не пришёл — не редиректим, иначе участника группы выкинет.
|
||
React.useEffect(() => {
|
||
if (route.nav === 'reports' && canSeeReports === false) {
|
||
navigate({ mode: 'submit', nav: 'new' }, { replace: true });
|
||
}
|
||
}, [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, а не
|
||
// видимый пользователю код). Сбрасываем при переходе на другую заявку —
|
||
// иначе при быстрой смене итемов в заголовке на миг мелькнёт код ПРЕДЫДУЩЕЙ.
|
||
const [drawerCode, setDrawerCode] = React.useState<string>('');
|
||
React.useEffect(() => {
|
||
setDrawerCode('');
|
||
}, [route.itemId, route.mode]);
|
||
|
||
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 => {
|
||
confirmDiscardFormChanges().then((ok) => {
|
||
if (!ok) return;
|
||
markFormClean();
|
||
setActiveCategoryId(categoryId);
|
||
}).catch(() => undefined);
|
||
};
|
||
|
||
const goto = (nav: NavSection): void => {
|
||
// Запоминаем раздел, чтобы следующий заход на портал (без rpnav в адресе)
|
||
// открыл его же, а не всегда «Новую заявку» (см. persistNav в routing.ts).
|
||
persistNav(nav);
|
||
// Клик по УЖЕ активному пункту меню не меняет маршрут, значит эффект
|
||
// загрузки в таблице не перезапустится сам. Шлём событие обновления —
|
||
// таблица на него подписана и перечитает данные фоном, не убирая с экрана
|
||
// текущие строки (см. RequestsTableView/SearchView).
|
||
if (nav === route.nav && route.mode === 'submit') emitRequestsChanged(true);
|
||
// Всё восстанавливаемое состояние раздела передаём ЯВНО, из запомненного.
|
||
// 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;
|
||
}
|
||
// Тип заявки в «Все заявки» — из запомненного (см. gotoAllRequestsType
|
||
// ниже, откуда он и запоминается). Не задан ни разу — undefined, раздел
|
||
// сам выберет первый доступный тип (см. AllRequestsFeature).
|
||
if (nav === 'allRequests') {
|
||
navigate({
|
||
mode: 'submit',
|
||
nav,
|
||
allTypeId: storedAllTypeId(),
|
||
page: storedPageFor(nav),
|
||
query: storedQueryFor(nav) || 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,
|
||
});
|
||
};
|
||
|
||
// Переход в «Все заявки» — либо с бокового меню (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);
|
||
// Смена фильтра — другой набор заявок, поэтому явно с первой страницы
|
||
// (и запоминаем именно её, иначе вернувшись сюда попали бы на старую).
|
||
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,
|
||
// Заявки нумерованные (см. shared/approval/listPartitions.ts) — строка
|
||
// таблицы уже знает, в каком СПИСКЕ она реально лежит (см. IMyRequest.listId
|
||
// в myRequests.service.ts), и это надо передать дальше, иначе архивная
|
||
// заявка откроется так, будто лежит в текущей партиции.
|
||
listId: request.listId,
|
||
ownTab: route.ownTab,
|
||
ownStatus: route.ownStatus,
|
||
allTypeId: route.allTypeId,
|
||
// Страница и запрос — часть того же списка под панелью: без них ссылка на
|
||
// открытую заявку вела бы на первую страницу без поиска.
|
||
page: route.page,
|
||
query: route.query,
|
||
});
|
||
|
||
// Закрывая панель, возвращаемся ровно в тот список, из которого её открыли —
|
||
// включая подвкладку и фильтр «Моих заявок».
|
||
const closeDrawer = (): void =>
|
||
navigate({
|
||
mode: 'submit',
|
||
nav: route.nav,
|
||
ownTab: route.ownTab,
|
||
ownStatus: route.ownStatus,
|
||
allTypeId: route.allTypeId,
|
||
page: route.page,
|
||
query: route.query,
|
||
});
|
||
|
||
// Закрытие панели РЕДАКТИРОВАНИЯ: там открыта форма, и её содержимое живёт
|
||
// только в памяти — уходя без сохранения, пользователь теряет правки.
|
||
const closeEditDrawer = (): void => {
|
||
confirmDiscardFormChanges().then((ok) => {
|
||
if (!ok) return;
|
||
markFormClean();
|
||
closeDrawer();
|
||
}).catch(() => undefined);
|
||
};
|
||
|
||
// Разделы бокового меню для пакетного Sidebar (счётчики — из navCounts).
|
||
// «Отчёты» — только у тех, кому разрешено (см. canSeeReports выше): скрыт из
|
||
// меню совсем, а не показан неактивным, — раздела для остальных как бы не существует.
|
||
//
|
||
// «Инструкция пользователя» — не раздел портала, а внешняя ссылка (docx в базе
|
||
// знаний). id 'instructions' НЕ входит в NavSection специально: обработчик
|
||
// onChange ниже перехватывает его до вызова goto() и открывает ссылку в новой
|
||
// вкладке, не меняя текущий раздел/маршрут.
|
||
const navItems: ISidebarItem[] = [
|
||
{ id: 'new', title: 'Новая заявка', icon: 'plus' },
|
||
{ id: 'approvals', title: 'На согласовании', icon: 'user-check', count: navCounts?.approvals },
|
||
{ id: 'execution', title: 'На исполнении', icon: 'helmet', count: navCounts?.execution },
|
||
{ id: 'rework', title: 'Доработка', icon: 'edit', count: navCounts?.rework },
|
||
{ id: 'own', title: 'Мои заявки', icon: 'clipboard' },
|
||
{ id: 'search', title: 'Поиск', icon: 'search' },
|
||
...(canSeeAllRequests ? [{ id: 'allRequests', title: 'Все заявки', icon: 'menu' } as ISidebarItem] : []),
|
||
...(canSeeReports ? [{ id: 'reports', title: 'Отчёты', icon: 'chart' } as ISidebarItem] : []),
|
||
{ id: 'instructions', title: 'Инструкция пользователя', icon: 'info' },
|
||
];
|
||
|
||
const handleNavChange = (id: string): void => {
|
||
if (id === 'instructions') {
|
||
window.open(USER_GUIDE_URL, '_blank', 'noopener');
|
||
return;
|
||
}
|
||
goto(id as NavSection);
|
||
};
|
||
|
||
return (
|
||
<section className={rootClasses}>
|
||
<IconSprite />
|
||
<ConfirmHost />
|
||
{/* Подмену личности легко забыть включённой — тогда портал молча показывал
|
||
бы чужие заявки и чужую очередь. Полосу поэтому не прячем и не даём
|
||
закрыть: она исчезает только когда свойство веб-части очищено. */}
|
||
{impersonatedEmail && (
|
||
<div className={styles.impersonationBar}>
|
||
Включена отладочная подмена: портал работает от имени{' '}
|
||
<strong>
|
||
{impersonatedDisplayName ? `${impersonatedDisplayName} (${impersonatedEmail})` : impersonatedEmail}
|
||
</strong>
|
||
. Очистите поле в свойствах веб-части, чтобы вернуться к своей учётной записи.
|
||
</div>
|
||
)}
|
||
<div className={styles.layout}>
|
||
<div className={styles.side}>
|
||
<Sidebar
|
||
items={navItems}
|
||
activeId={route.nav}
|
||
onChange={handleNavChange}
|
||
brand="Портал заявок"
|
||
collapsed={navCollapsed}
|
||
onToggleCollapsed={() => setNavCollapsed((v) => !v)}
|
||
mobileOpen={mobileNavOpen}
|
||
onMobileClose={() => setMobileNavOpen(false)}
|
||
/>
|
||
</div>
|
||
|
||
<main className={styles.main}>
|
||
<div className={styles.mobileNav}>
|
||
<SidebarToggle onClick={() => setMobileNavOpen(true)} />
|
||
</div>
|
||
{/* Раздел «Новая заявка» держим СМОНТИРОВАННЫМ и просто прячем стилем,
|
||
когда открыта другая вкладка. Иначе React размонтировал бы форму, и
|
||
всё введённое пропадало бы при переключении на «Мои заявки» и
|
||
обратно. Ключ завязан на «создать на основе»: обычные переключения
|
||
вкладок его не меняют (состояние живёт), а вот открытие новой
|
||
заявки на основе исходной пересоздаёт форму — SubmitFeature читает
|
||
initialForm только при монтировании. */}
|
||
<div
|
||
className={route.nav === 'new' ? undefined : styles.hiddenPane}
|
||
// Делегирование: любой пользовательский ввод внутри раздела помечает
|
||
// форму изменённой. Так не нужно инструментировать каждую форму по
|
||
// отдельности, и программное заполнение (предзаполнение «на основе»)
|
||
// сюда не попадает — React выставляет значения без этих событий.
|
||
onInput={markFormDirty}
|
||
onChange={markFormDirty}
|
||
>
|
||
{isOpenedInTeams(hasTeamsContext) && <TeamsBrowserNotice />}
|
||
<div className={styles.tabsWrap}>
|
||
<CategoryTabs
|
||
categories={CATEGORIES}
|
||
activeId={activeCategoryId}
|
||
onChange={changeCategory}
|
||
/>
|
||
</div>
|
||
<SubmitFeature
|
||
key={
|
||
route.mode === 'create' && route.formKey
|
||
? `create-${route.formKey}-${route.sourceId ?? route.positionId ?? 'blank'}`
|
||
: 'blank'
|
||
}
|
||
context={context}
|
||
activeCategoryId={activeCategoryId}
|
||
initialForm={
|
||
route.mode === 'create' && route.formKey
|
||
? {
|
||
formKey: route.formKey,
|
||
sourceId: route.sourceId,
|
||
positionId: route.positionId,
|
||
nomenclatureId: route.nomenclatureId,
|
||
storeName: route.storeName
|
||
}
|
||
: undefined
|
||
}
|
||
onFormSelected={(formKey) => navigate({ mode: 'create', nav: 'new', formKey })}
|
||
onOpenAllRequests={gotoAllRequestsForType}
|
||
/>
|
||
</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} />
|
||
)}
|
||
|
||
{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}
|
||
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={drawerCode ? `Заявка №${drawerCode}` : 'Заявка'} onClose={closeDrawer}>
|
||
<ApproveFeature
|
||
context={context}
|
||
formKey={route.formKey}
|
||
itemId={route.itemId}
|
||
listId={route.listId}
|
||
onCodeLoaded={setDrawerCode}
|
||
/>
|
||
</Drawer>
|
||
)}
|
||
|
||
{/* Своя незавершённая заявка — та же панель, но с формой. Ввод здесь тоже
|
||
помечает состояние изменённым, поэтому обёртка ловит input/change. */}
|
||
{route.mode === 'edit' && route.formKey && route.itemId && (
|
||
<Drawer title={drawerCode ? `Заявка №${drawerCode}` : 'Заявка'} onClose={closeEditDrawer}>
|
||
<div onInput={markFormDirty} onChange={markFormDirty}>
|
||
<EditFeature
|
||
context={context}
|
||
formKey={route.formKey}
|
||
itemId={route.itemId}
|
||
listId={route.listId}
|
||
onClose={closeDrawer}
|
||
onCodeLoaded={setDrawerCode}
|
||
/>
|
||
</div>
|
||
</Drawer>
|
||
)}
|
||
</section>
|
||
);
|
||
};
|
||
|
||
export default RequestPortal;
|