Редактирование заявок у конкретного пользователя (#14)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/14
This commit was merged in pull request #14.
This commit is contained in:
@@ -1,23 +1,63 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
// Раскладка — тот же боковой рейл + основная область, что и в request-portal
|
||||
// (@tnsu/ui-kit Sidebar, см. RequestPortal.module.scss): единый стиль
|
||||
// навигации во всех веб-частях Tnsu. В отличие от request-portal (рейл во всю
|
||||
// высоту окна, edge-to-edge), здесь виджет — ограниченная карточка на странице
|
||||
// SharePoint, поэтому без sticky/100vh-трюков: рейл просто растягивается по
|
||||
// высоте своего соседа (.main) через align-items: stretch.
|
||||
.shell {
|
||||
background: linear-gradient(180deg, #F6F8FA 0%, #EEF2F6 100%);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-radius: $r-lg;
|
||||
border: 1px solid $gray-200;
|
||||
overflow: hidden;
|
||||
box-shadow: $sh-1;
|
||||
color: $gray-900;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-body;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.side {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
background: $navy-800;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0; // чтобы контент не распирал колонку
|
||||
background: linear-gradient(180deg, #F6F8FA 0%, #EEF2F6 100%);
|
||||
color: $gray-900;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 28px 30px 36px;
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
// Мобильная кнопка-бургер (открывает выезжающий Sidebar). На десктопе скрыта.
|
||||
.mobileNav {
|
||||
display: none;
|
||||
padding: $sp-4 $sp-6 0;
|
||||
}
|
||||
|
||||
// < 768px: рейл сам уходит в position:fixed и уезжает за левый край экрана,
|
||||
// пока не открыт (см. @tnsu/ui-kit Sidebar — это его собственный CSS, чисто
|
||||
// media-query, без участия JS) — НЕ display:none на .side: рейл вёрстке всё
|
||||
// равно нужен в потоке, чтобы при mobileOpen выехать поверх контента.
|
||||
@media (max-width: 768px) {
|
||||
.mobileNav {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.side {
|
||||
align-self: flex-start;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.centerState {
|
||||
padding: 60px 30px;
|
||||
text-align: center;
|
||||
|
||||
@@ -1,19 +1,47 @@
|
||||
import * as React from 'react';
|
||||
import styles from './AdminApp.module.scss';
|
||||
import { TopNav } from './TopNav';
|
||||
import '@tnsu/ui-kit/styles.css';
|
||||
import { IconSprite, Sidebar, SidebarToggle } from '@tnsu/ui-kit';
|
||||
import type { ISidebarItem } from '@tnsu/ui-kit';
|
||||
import { ScreenKey } from './types';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||
import { SharePointListsClient } from '../../services/sharePointLists';
|
||||
import { RequestManageService } from '../../services/requestManageService';
|
||||
import { RequestType, TemplateRoute } from '../../models';
|
||||
import { navigate, useRoute } from '../../routing';
|
||||
import { navigate, replaceRoute, useRoute } from '../../routing';
|
||||
import { texts } from '../../texts';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
import { DashboardScreen } from '../screens/DashboardScreen';
|
||||
import { RequestTypesScreen } from '../screens/RequestTypesScreen';
|
||||
import { RoutesScreen } from '../screens/RoutesScreen';
|
||||
import { RouteStepsScreen } from '../screens/RouteStepsScreen';
|
||||
import { ResolveScreen } from '../screens/ResolveScreen';
|
||||
import { ManageRequestsScreen } from '../screens/ManageRequestsScreen';
|
||||
import { ManageByUserScreen } from '../screens/ManageByUserScreen';
|
||||
|
||||
// Тот же боковой рейл (@tnsu/ui-kit Sidebar), что и в request-portal — единый
|
||||
// стиль навигации во всех веб-частях Tnsu. 'routeSteps' сюда не входит: это не
|
||||
// отдельная вкладка URL, а состояние вкладки 'routes' при открытых шагах
|
||||
// конкретного маршрута (см. routing.ts) — подсвечиваем в этом случае 'routes'.
|
||||
const NAV_ITEMS: ISidebarItem[] = [
|
||||
{ id: 'dashboard', title: texts.nav.dashboard, icon: 'chart' },
|
||||
{ id: 'requestTypes', title: texts.nav.requestTypes, icon: 'document' },
|
||||
{ id: 'routes', title: texts.nav.routes, icon: 'send' },
|
||||
{ id: 'resolve', title: texts.nav.resolve, icon: 'search' },
|
||||
{ id: 'manageRequests', title: texts.nav.manageRequests, icon: 'clipboard' },
|
||||
{ id: 'manageByUser', title: texts.nav.manageByUser, icon: 'user-check' }
|
||||
];
|
||||
|
||||
function sidebarActiveId(tab: ScreenKey): string {
|
||||
return tab === 'routeSteps' ? 'routes' : tab;
|
||||
}
|
||||
|
||||
// Последняя открытая вкладка. В адресе она и так живёт (aatab, см. routing.ts),
|
||||
// но веб-часть часто открывают по «чистой» ссылке на страницу SharePoint, без
|
||||
// query — и тогда пользователь каждый раз приземлялся на дашборд, даже если
|
||||
// работает всегда в одном разделе.
|
||||
const TAB_STORAGE_KEY = 'approvalAdmin.activeTab';
|
||||
|
||||
export interface IAdminAppProps {
|
||||
api: ApprovalRouteApi;
|
||||
@@ -28,8 +56,31 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
||||
const route = useRoute();
|
||||
const [selectedRoute, setSelectedRoute] = React.useState<TemplateRoute | undefined>();
|
||||
const [selectedRequestType, setSelectedRequestType] = React.useState<RequestType | undefined>();
|
||||
const [navCollapsed, setNavCollapsed] = React.useState(false);
|
||||
const [mobileNavOpen, setMobileNavOpen] = React.useState(false);
|
||||
|
||||
const navigateTab = (key: ScreenKey): void => navigate({ tab: key });
|
||||
const [storedTab, setStoredTab] = useLocalStorageState<ScreenKey>(TAB_STORAGE_KEY, 'dashboard');
|
||||
|
||||
// Восстанавливаем последнюю вкладку ТОЛЬКО когда в адресе её нет: глубокая
|
||||
// ссылка (в т.ч. присланная коллегой) всегда важнее запомненного выбора.
|
||||
// Один раз на монтировании — дальше вкладку меняет только сам пользователь.
|
||||
React.useEffect(() => {
|
||||
const explicitTab = new URLSearchParams(window.location.search).get('aatab');
|
||||
if (explicitTab || storedTab === 'dashboard') return;
|
||||
replaceRoute({ tab: storedTab });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 'routeSteps' сюда не попадёт: useRoute отдаёт только вкладки из TABS, а шаги
|
||||
// маршрута — состояние вкладки 'routes' (см. routing.ts).
|
||||
React.useEffect(() => {
|
||||
setStoredTab(route.tab);
|
||||
}, [route.tab, setStoredTab]);
|
||||
|
||||
const navigateTab = (key: ScreenKey): void => {
|
||||
setMobileNavOpen(false);
|
||||
navigate({ tab: key });
|
||||
};
|
||||
|
||||
const openSteps = (r: TemplateRoute): void => {
|
||||
setSelectedRoute(r);
|
||||
@@ -76,41 +127,61 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
||||
|
||||
return (
|
||||
<div className={styles.shell}>
|
||||
<TopNav active={route.tab} onSelect={navigateTab} />
|
||||
<div className={styles.content}>
|
||||
{route.tab === 'dashboard' && <DashboardScreen api={api} onNavigate={navigateTab} />}
|
||||
{route.tab === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
|
||||
{route.tab === 'routes' && !route.routeId && (
|
||||
<RoutesScreen
|
||||
api={api}
|
||||
dictionary={dictionary}
|
||||
initialFilterRequestTypeId={route.routesFilter}
|
||||
onOpenSteps={openSteps}
|
||||
/>
|
||||
)}
|
||||
{route.tab === 'routes' && route.routeId && selectedRoute && (
|
||||
<RouteStepsScreen
|
||||
api={api}
|
||||
route={selectedRoute}
|
||||
requestType={selectedRequestType}
|
||||
dictionary={dictionary}
|
||||
sharePoint={sharePoint}
|
||||
onBack={() => navigate({ tab: 'routes', routesFilter: route.routesFilter })}
|
||||
/>
|
||||
)}
|
||||
{route.tab === 'resolve' && (
|
||||
<ResolveScreen
|
||||
api={api}
|
||||
dictionary={dictionary}
|
||||
sharePoint={sharePoint}
|
||||
currentUserEmail={currentUserEmail}
|
||||
route={route.resolve}
|
||||
/>
|
||||
)}
|
||||
{route.tab === 'manageRequests' && (
|
||||
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} />
|
||||
)}
|
||||
<IconSprite />
|
||||
<div className={styles.side}>
|
||||
<Sidebar
|
||||
items={NAV_ITEMS}
|
||||
activeId={sidebarActiveId(route.tab)}
|
||||
onChange={id => navigateTab(id as ScreenKey)}
|
||||
brand={texts.nav.title}
|
||||
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>
|
||||
<div className={styles.content}>
|
||||
{route.tab === 'dashboard' && <DashboardScreen api={api} onNavigate={navigateTab} />}
|
||||
{route.tab === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
|
||||
{route.tab === 'routes' && !route.routeId && (
|
||||
<RoutesScreen
|
||||
api={api}
|
||||
dictionary={dictionary}
|
||||
initialFilterRequestTypeId={route.routesFilter}
|
||||
onOpenSteps={openSteps}
|
||||
/>
|
||||
)}
|
||||
{route.tab === 'routes' && route.routeId && selectedRoute && (
|
||||
<RouteStepsScreen
|
||||
api={api}
|
||||
route={selectedRoute}
|
||||
requestType={selectedRequestType}
|
||||
dictionary={dictionary}
|
||||
sharePoint={sharePoint}
|
||||
onBack={() => navigate({ tab: 'routes', routesFilter: route.routesFilter })}
|
||||
/>
|
||||
)}
|
||||
{route.tab === 'resolve' && (
|
||||
<ResolveScreen
|
||||
api={api}
|
||||
dictionary={dictionary}
|
||||
sharePoint={sharePoint}
|
||||
currentUserEmail={currentUserEmail}
|
||||
route={route.resolve}
|
||||
/>
|
||||
)}
|
||||
{route.tab === 'manageRequests' && (
|
||||
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} />
|
||||
)}
|
||||
{route.tab === 'manageByUser' && (
|
||||
<ManageByUserScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manageByUser} />
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.topnav {
|
||||
background: $grad-header;
|
||||
padding: 0 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 54px;
|
||||
box-shadow: 0 1px 0 rgba(212,160,30,.35);
|
||||
border-radius: $r-md $r-md 0 0;
|
||||
// Без переноса: высота шапки фиксирована (54px), поэтому перенесённая
|
||||
// строка вкладок вылезала бы за её пределы вместе с подчёркиванием.
|
||||
// Вместо переноса вкладки скроллятся по горизонтали.
|
||||
flex-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-right: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brandText {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
letter-spacing: .4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-wrap: nowrap;
|
||||
// min-width: 0 обязателен — иначе flex-элемент не сожмётся уже своего
|
||||
// содержимого и overflow-x никогда не сработает.
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
// Делает контейнер offsetParent'ом для вкладок: автопрокрутка в TopNav.tsx
|
||||
// считает offsetLeft относительно него. Без этого offsetLeft отсчитывался
|
||||
// бы от body и прокрутка уезжала бы не туда.
|
||||
position: relative;
|
||||
|
||||
// Тонкая полупрозрачная полоса — не спрятана полностью: с добавлением новых
|
||||
// вкладок (сейчас их 6) они не всегда помещаются в узкую панель, и без
|
||||
// видимого скроллбара непонятно, что часть вкладок обрезана, а не пропала.
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, .35) transparent;
|
||||
&::-webkit-scrollbar { height: 4px; }
|
||||
&::-webkit-scrollbar-track { background: transparent; }
|
||||
&::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, .35); border-radius: 2px; }
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
padding: 0 13px;
|
||||
border: none;
|
||||
background: 0;
|
||||
color: rgba(255,255,255,.5);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: .18s;
|
||||
height: 54px;
|
||||
border-bottom: 2px solid transparent;
|
||||
letter-spacing: .2px;
|
||||
|
||||
&:hover { color: rgba(255,255,255,.85); }
|
||||
&.on { color: $gold-300; border-bottom-color: $gold-400; }
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import styles from './TopNav.module.scss';
|
||||
import { ScreenKey } from './types';
|
||||
import { texts } from '../../texts';
|
||||
|
||||
interface ITab {
|
||||
key: ScreenKey;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const TABS: ITab[] = [
|
||||
{ key: 'dashboard', label: texts.nav.dashboard },
|
||||
{ key: 'requestTypes', label: texts.nav.requestTypes },
|
||||
{ key: 'routes', label: texts.nav.routes },
|
||||
{ key: 'resolve', label: texts.nav.resolve },
|
||||
{ key: 'manageRequests', label: texts.nav.manageRequests }
|
||||
];
|
||||
|
||||
export interface ITopNavProps {
|
||||
active: ScreenKey;
|
||||
onSelect: (key: ScreenKey) => void;
|
||||
}
|
||||
|
||||
function isTabActive(active: ScreenKey, tabKey: ScreenKey): boolean {
|
||||
// Экран шагов маршрута — вложенный, подсвечиваем вкладку «Маршруты».
|
||||
return active === tabKey || (active === 'routeSteps' && tabKey === 'routes');
|
||||
}
|
||||
|
||||
export const TopNav: React.FC<ITopNavProps> = ({ active, onSelect }) => {
|
||||
const tabsRef = React.useRef<HTMLDivElement>(null);
|
||||
const activeTabRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Вкладки скроллятся по горизонтали, поэтому активная может оказаться за
|
||||
// краем. Подкручиваем контейнер вручную (не scrollIntoView — тот способен
|
||||
// утащить за собой прокрутку всей страницы SharePoint).
|
||||
React.useEffect(() => {
|
||||
const reveal = (): void => {
|
||||
const container = tabsRef.current;
|
||||
const tab = activeTabRef.current;
|
||||
if (!container || !tab) return;
|
||||
|
||||
const left = tab.offsetLeft;
|
||||
const width = tab.offsetWidth;
|
||||
|
||||
if (width > container.clientWidth) {
|
||||
// Вкладка шире видимой полосы — показываем её начало, чтобы подпись
|
||||
// читалась с первого слова.
|
||||
container.scrollLeft = left;
|
||||
} else if (left < container.scrollLeft) {
|
||||
container.scrollLeft = left;
|
||||
} else if (left + width > container.scrollLeft + container.clientWidth) {
|
||||
container.scrollLeft = left + width - container.clientWidth;
|
||||
}
|
||||
};
|
||||
|
||||
reveal();
|
||||
|
||||
// Шрифты (Lora/Inter) подключаются рантаймом: пока они не применились,
|
||||
// ширины вкладок другие и позиция прокрутки получается устаревшей.
|
||||
const fonts = (document as Document & { fonts?: FontFaceSet }).fonts;
|
||||
let cancelled = false;
|
||||
if (fonts?.ready) {
|
||||
fonts.ready.then(() => { if (!cancelled) reveal(); }).catch(() => { /* не критично */ });
|
||||
}
|
||||
|
||||
window.addEventListener('resize', reveal);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener('resize', reveal);
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
return (
|
||||
<nav className={styles.topnav}>
|
||||
<div className={styles.brand}>
|
||||
<div className={styles.brandText}>{texts.nav.title}</div>
|
||||
</div>
|
||||
<div className={styles.tabs} ref={tabsRef}>
|
||||
{TABS.map(tab => {
|
||||
const on = isTabActive(active, tab.key);
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
ref={on ? activeTabRef : undefined}
|
||||
className={`${styles.tab} ${on ? styles.on : ''}`}
|
||||
onClick={() => onSelect(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -1 +1 @@
|
||||
export type ScreenKey = 'dashboard' | 'requestTypes' | 'routes' | 'routeSteps' | 'resolve' | 'manageRequests';
|
||||
export type ScreenKey = 'dashboard' | 'requestTypes' | 'routes' | 'routeSteps' | 'resolve' | 'manageRequests' | 'manageByUser';
|
||||
|
||||
@@ -69,6 +69,12 @@ export const DashboardScreen: React.FC<IDashboardScreenProps> = ({ api, onNaviga
|
||||
description={texts.dashboard.manageRequestsCard.description}
|
||||
onClick={() => onNavigate('manageRequests')}
|
||||
/>
|
||||
<NavCard
|
||||
icon="people"
|
||||
title={texts.dashboard.manageByUserCard.title}
|
||||
description={texts.dashboard.manageByUserCard.description}
|
||||
onClick={() => onNavigate('manageByUser')}
|
||||
/>
|
||||
</CardGrid>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from 'react';
|
||||
import { texts } from '../../texts';
|
||||
import { Button, Modal } from '../ui';
|
||||
|
||||
export type DuplicateChoice = 'delete' | 'skip';
|
||||
|
||||
export interface IManageByUserDuplicateModalProps {
|
||||
title: string;
|
||||
message: string;
|
||||
onChoice: (choice: DuplicateChoice, forAll: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* «Да / Да для всех / Нет / Нет для всех» — классический диалог разрешения
|
||||
* конфликтов при пакетной операции (как в проводнике при копировании файлов).
|
||||
* Закрытие крестиком/подложкой равносильно «Нет» (без «для всех» — иначе
|
||||
* случайный клик мимо тихо остановил бы весь оставшийся список).
|
||||
*/
|
||||
export const ManageByUserDuplicateModal: React.FC<IManageByUserDuplicateModalProps> = ({ title, message, onChoice }) => (
|
||||
<Modal
|
||||
title={title}
|
||||
onClose={() => onChoice('skip', false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => onChoice('skip', false)}>{texts.manageByUser.duplicateSkip}</Button>
|
||||
<Button variant="secondary" onClick={() => onChoice('skip', true)}>{texts.manageByUser.duplicateSkipAll}</Button>
|
||||
<Button variant="danger" icon="trash" onClick={() => onChoice('delete', false)}>{texts.manageByUser.duplicateDelete}</Button>
|
||||
<Button variant="danger" icon="trash" onClick={() => onChoice('delete', true)}>{texts.manageByUser.duplicateDeleteAll}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>{message}</p>
|
||||
</Modal>
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.panelHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.panelTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.panelActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// Поле «Заменить на» + кнопка «поменять местами» справа от него: кнопка стоит
|
||||
// на уровне САМОГО контрола, а не подписи, поэтому живёт внутри Field, а не
|
||||
// отдельной колонкой сетки.
|
||||
.targetRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// min-width: 0 обязателен — иначе flex-элемент не сожмётся уже своего
|
||||
// содержимого, и длинное ФИО в селекте вытолкнет кнопку за край поля.
|
||||
.targetSelect {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
import * as React from 'react';
|
||||
import styles from './ManageByUserScreen.module.scss';
|
||||
import { DuplicateChoice, ManageByUserDuplicateModal } from './ManageByUserDuplicateModal';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||
import { RequestManageService } from '../../services/requestManageService';
|
||||
import { ApiClientError } from '../../services/httpClient';
|
||||
import { REQUEST_STATUS, RequestListPartition, UserApprovalRow, UserTemplateStepRow } from '../../models';
|
||||
import { routeStepStatusLabels, texts } from '../../texts';
|
||||
import { shortGuid } from '../../utils/guid';
|
||||
import { describeStepCondition } from '../../utils/stepExecutor';
|
||||
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
||||
import { reportError } from '../../utils/errors';
|
||||
import { mapWithLimit } from '../../utils/parallel';
|
||||
import { IManageByUserRouteParams, replaceRoute } from '../../routing';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { useRequestTypeListsAll } from '../../hooks/useRequestTypeListsAll';
|
||||
import { useTemplateRouteStepsByExecutor } from '../../hooks/useTemplateRouteStepsByExecutor';
|
||||
import { useTemplateRoutesLookup } from '../../hooks/useTemplateRoutesLookup';
|
||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
||||
import {
|
||||
ActionsCell,
|
||||
Alert,
|
||||
Badge,
|
||||
BadgeVariant,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Checkbox,
|
||||
DataTable,
|
||||
Field,
|
||||
FormGrid,
|
||||
IColumn,
|
||||
IconButton,
|
||||
PageHeader,
|
||||
Panel,
|
||||
PrimaryCell,
|
||||
ProgressOverlay,
|
||||
SearchableSelect,
|
||||
Toast
|
||||
} from '../ui';
|
||||
|
||||
export interface IManageByUserScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||
dictionary?: PortalDictionaryClient;
|
||||
requestManage: RequestManageService;
|
||||
/** Заполнение из URL (глубокая ссылка на конкретного сотрудника) — см. routing.ts. */
|
||||
route?: IManageByUserRouteParams;
|
||||
}
|
||||
|
||||
// Выбор (кого меняем и на кого) переживает перезагрузку страницы — как и выбор
|
||||
// заявки в ManageRequestsScreen (тот же приём, отдельный ключ).
|
||||
const SELECTION_STORAGE_KEY = 'approvalAdmin.manageByUserSelection';
|
||||
|
||||
interface IPersistedSelection {
|
||||
employeeEmail: string;
|
||||
targetEmail: string;
|
||||
}
|
||||
|
||||
const EMPTY_SELECTION: IPersistedSelection = { employeeEmail: '', targetEmail: '' };
|
||||
|
||||
// Активные статусы строки для этого экрана — сознательно уже, чем
|
||||
// isEditableRowStatus (models/requestManage.ts): «Возвращена на доработку» и
|
||||
// черновики сюда не входят (согласовано отдельно, см. план реализации).
|
||||
const ACTIVE_STATUSES: string[] = [REQUEST_STATUS.queued, REQUEST_STATUS.onApproval, REQUEST_STATUS.onExecution];
|
||||
|
||||
// Итог пакетной операции по одной панели.
|
||||
interface IBulkOutcome {
|
||||
ok: number;
|
||||
fail: number;
|
||||
/** Дубли, снятые удалением старой строки/шага (пользователь выбрал «удалить»). */
|
||||
deleted: number;
|
||||
/** Дубли, оставленные как есть (пользователь выбрал «пропустить»). */
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
// Та же цветовая группировка статуса заявки, что и в ManageRequestsScreen
|
||||
// (дублирование небольшое и не стоит выносить ради одной строки).
|
||||
function statusVariant(status: string): BadgeVariant {
|
||||
if (status === REQUEST_STATUS.onExecution) return 'blue';
|
||||
if (status === REQUEST_STATUS.queued) return 'neutral';
|
||||
return 'gold';
|
||||
}
|
||||
|
||||
function liveRowKey(r: UserApprovalRow): string {
|
||||
return `${r.listId}:${r.id}`;
|
||||
}
|
||||
|
||||
interface IReplaceScope {
|
||||
live: UserApprovalRow[];
|
||||
templates: UserTemplateStepRow[];
|
||||
}
|
||||
|
||||
export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, dictionary, requestManage, route }) => {
|
||||
const requestTypes = useRequestTypes(api);
|
||||
const listSeries = useRequestTypeListsAll(api);
|
||||
const employees = usePortalEmployees(dictionary);
|
||||
const dictionaries = usePortalDictionaries(dictionary);
|
||||
|
||||
const [selection, setSelection] = useLocalStorageState<IPersistedSelection>(SELECTION_STORAGE_KEY, EMPTY_SELECTION);
|
||||
const { employeeEmail, targetEmail } = selection;
|
||||
|
||||
const selectEmployee = (email: string): void => setSelection(prev => ({ ...prev, employeeEmail: email }));
|
||||
const selectTarget = (email: string): void => setSelection(prev => ({ ...prev, targetEmail: email }));
|
||||
const swapEmployees = (): void =>
|
||||
setSelection(prev => ({ employeeEmail: prev.targetEmail, targetEmail: prev.employeeEmail }));
|
||||
|
||||
// Открыли по глубокой ссылке (aabuser/aabtarget в URL) — перекрывает то, что
|
||||
// было в localStorage. Только на монтировании: route меняется при каждом
|
||||
// собственном replaceRoute ниже (тот же приём, что в ManageRequestsScreen).
|
||||
React.useEffect(() => {
|
||||
if (route?.userEmail) setSelection({ employeeEmail: route.userEmail, targetEmail: route.targetEmail });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Держим URL синхронным с выбором — открытый экран можно скопировать и
|
||||
// прислать коллеге. replaceRoute, а не navigate: смена сотрудника — правка
|
||||
// состояния текущего экрана, а не отдельный шаг в истории браузера.
|
||||
React.useEffect(() => {
|
||||
replaceRoute({
|
||||
tab: 'manageByUser',
|
||||
manageByUser: employeeEmail ? { userEmail: employeeEmail, targetEmail } : undefined
|
||||
});
|
||||
}, [employeeEmail, targetEmail]);
|
||||
|
||||
const partitionsByType = React.useMemo(() => {
|
||||
const map = new Map<number, RequestListPartition[]>();
|
||||
listSeries.items.forEach(s => map.set(s.requestTypeId, s.partitions));
|
||||
return map;
|
||||
}, [listSeries.items]);
|
||||
|
||||
const [liveRows, setLiveRows] = React.useState<UserApprovalRow[]>([]);
|
||||
const [liveLoading, setLiveLoading] = React.useState(false);
|
||||
const [liveError, setLiveError] = React.useState<string | undefined>();
|
||||
const [checkedLiveKeys, setCheckedLiveKeys] = React.useState<Set<string>>(new Set());
|
||||
|
||||
const reloadLiveRows = React.useCallback(() => {
|
||||
if (!employeeEmail || requestTypes.isLoading || listSeries.isLoading) return;
|
||||
setLiveLoading(true);
|
||||
setLiveError(undefined);
|
||||
requestManage
|
||||
.findActiveApprovalRowsForApprover(requestTypes.items, partitionsByType, employeeEmail, ACTIVE_STATUSES)
|
||||
.then(setLiveRows)
|
||||
.catch((e: Error) => setLiveError(reportError(e, 'ManageByUserScreen: findActiveApprovalRowsForApprover')))
|
||||
.finally(() => setLiveLoading(false));
|
||||
}, [requestManage, requestTypes.items, requestTypes.isLoading, partitionsByType, listSeries.isLoading, employeeEmail]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLiveRows([]);
|
||||
setCheckedLiveKeys(new Set());
|
||||
reloadLiveRows();
|
||||
}, [reloadLiveRows]);
|
||||
|
||||
const templateSteps = useTemplateRouteStepsByExecutor(api, employeeEmail || undefined);
|
||||
const routeIds = React.useMemo(() => templateSteps.items.map(s => s.routeId), [templateSteps.items]);
|
||||
const routesLookup = useTemplateRoutesLookup(api, routeIds);
|
||||
const [checkedTemplateIds, setCheckedTemplateIds] = React.useState<Set<number>>(new Set());
|
||||
|
||||
const templateRows: UserTemplateStepRow[] = React.useMemo(
|
||||
() =>
|
||||
templateSteps.items.map(step => {
|
||||
const route = routesLookup.byId.get(step.routeId);
|
||||
const requestType = route ? requestTypes.items.filter(rt => rt.id === route.requestTypeId)[0] : undefined;
|
||||
const companyName = route?.companyId
|
||||
? dictionaries.companies.find(c => c.id === route.companyId)?.name ?? shortGuid(route.companyId)
|
||||
: texts.routeSteps.anyCompany;
|
||||
const departmentName = route?.departmentId
|
||||
? dictionaries.departments.find(d => d.id === route.departmentId)?.name ?? shortGuid(route.departmentId)
|
||||
: texts.routeSteps.anyDepartment;
|
||||
return {
|
||||
...step,
|
||||
requestTypeName: requestType?.name ?? (route ? `#${route.requestTypeId}` : texts.common.empty),
|
||||
companyName,
|
||||
departmentName
|
||||
};
|
||||
}),
|
||||
[templateSteps.items, routesLookup.byId, requestTypes.items, dictionaries.companies, dictionaries.departments]
|
||||
);
|
||||
|
||||
const toggleLive = (row: UserApprovalRow): void =>
|
||||
setCheckedLiveKeys(prev => {
|
||||
const next = new Set(prev);
|
||||
const key = liveRowKey(row);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
|
||||
const toggleTemplate = (row: UserTemplateStepRow): void =>
|
||||
setCheckedTemplateIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(row.id)) next.delete(row.id);
|
||||
else next.add(row.id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const checkedLiveRows = liveRows.filter(r => checkedLiveKeys.has(liveRowKey(r)));
|
||||
const checkedTemplateRows = templateRows.filter(r => checkedTemplateIds.has(r.id));
|
||||
|
||||
const allLiveChecked = liveRows.length > 0 && checkedLiveRows.length === liveRows.length;
|
||||
const toggleAllLive = (): void => setCheckedLiveKeys(allLiveChecked ? new Set() : new Set(liveRows.map(liveRowKey)));
|
||||
|
||||
const allTemplatesChecked = templateRows.length > 0 && checkedTemplateRows.length === templateRows.length;
|
||||
const toggleAllTemplates = (): void =>
|
||||
setCheckedTemplateIds(allTemplatesChecked ? new Set() : new Set(templateRows.map(r => r.id)));
|
||||
|
||||
const [resultToast, setResultToast] = React.useState<{ message: string; kind: 'ok' | 'err' } | undefined>();
|
||||
// Пока задан — интерфейс заблокирован подложкой с полосой прогресса
|
||||
// (ProgressOverlay), и запустить вторую замену поверх первой нельзя.
|
||||
const [progress, setProgress] = React.useState<{ done: number; total: number } | undefined>();
|
||||
|
||||
// Открытый диалог разрешения дубля + то, чем разбудить ожидающий его цикл
|
||||
// замены (см. askDuplicate). Пакетная обработка идёт ПОСЛЕДОВАТЕЛЬНО именно
|
||||
// из-за этого диалога: параллельные ветки открыли бы N окон разом.
|
||||
const [duplicatePrompt, setDuplicatePrompt] =
|
||||
React.useState<{ message: string; resolve: (choice: DuplicateChoice) => void } | undefined>();
|
||||
// «…для всех» на время ОДНОЙ пакетной операции — ref, а не state: значение
|
||||
// читается внутри уже запущенного цикла, и перерисовка ему не нужна.
|
||||
const duplicatePolicy = React.useRef<DuplicateChoice | undefined>(undefined);
|
||||
|
||||
// Шаг вперёд по полосе прогресса. Вызывается ПЕРЕД обработкой очередной
|
||||
// строки: показывает «делаю N-ю из M», а не «N уже готово».
|
||||
const advanceProgress = (): void =>
|
||||
setProgress(prev => (prev ? { done: Math.min(prev.done + 1, prev.total), total: prev.total } : prev));
|
||||
|
||||
const askDuplicate = (message: string): Promise<DuplicateChoice> => {
|
||||
if (duplicatePolicy.current) return Promise.resolve(duplicatePolicy.current);
|
||||
return new Promise<DuplicateChoice>(resolve => setDuplicatePrompt({ message, resolve }));
|
||||
};
|
||||
|
||||
const answerDuplicate = (choice: DuplicateChoice, forAll: boolean): void => {
|
||||
if (forAll) duplicatePolicy.current = choice;
|
||||
const pending = duplicatePrompt;
|
||||
setDuplicatePrompt(undefined);
|
||||
pending?.resolve(choice);
|
||||
};
|
||||
|
||||
/**
|
||||
* Замена в живых заявках — тремя фазами, чтобы совместить параллельность с
|
||||
* интерактивным разрешением дублей:
|
||||
*
|
||||
* 1. ПАРАЛЛЕЛЬНО проверяем каждую строку на дубль (чтение, ничего не пишет)
|
||||
* и заодно параллельно заменяем все «чистые» строки;
|
||||
* 2. ПОСЛЕДОВАТЕЛЬНО спрашиваем пользователя по каждому найденному дублю —
|
||||
* диалог по определению один за раз (параллельно открылось бы N окон);
|
||||
* 3. ПАРАЛЛЕЛЬНО удаляем те строки, по которым выбрали «удалить».
|
||||
*
|
||||
* Дубль (тот же человек уже стоит в этой же фазе этой же заявки) SharePoint
|
||||
* сам не запрещает — если не проверить, в маршруте появились бы две строки на
|
||||
* одного человека, и заявка ушла бы ему на согласование дважды.
|
||||
*/
|
||||
async function bulkReplaceLive(rows: UserApprovalRow[], email: string, position: string): Promise<IBulkOutcome> {
|
||||
const outcome: IBulkOutcome = { ok: 0, fail: 0, deleted: 0, skipped: 0 };
|
||||
|
||||
// Фаза 1: проверка дублей. Прогресс здесь не двигаем — это ещё не работа по
|
||||
// строке, а подготовка (строка может оказаться и заменена, и удалена).
|
||||
const checks = await mapWithLimit(rows, row => requestManage.hasApproverInSamePhase(row, email));
|
||||
|
||||
const clean: UserApprovalRow[] = [];
|
||||
const conflicting: UserApprovalRow[] = [];
|
||||
checks.forEach((result, i) => {
|
||||
if (!result.ok) {
|
||||
reportError(result.error, 'ManageByUserScreen: hasApproverInSamePhase');
|
||||
outcome.fail += 1;
|
||||
advanceProgress();
|
||||
return;
|
||||
}
|
||||
(result.value ? conflicting : clean).push(rows[i]);
|
||||
});
|
||||
|
||||
// Фаза 2: «чистые» строки — параллельно, друг от друга не зависят.
|
||||
const replaced = await mapWithLimit(
|
||||
clean,
|
||||
row => requestManage.reassignApprover(row, row.id, email, position),
|
||||
{ onSettled: advanceProgress }
|
||||
);
|
||||
replaced.forEach(result => {
|
||||
if (result.ok) outcome.ok += 1;
|
||||
else {
|
||||
reportError(result.error, 'ManageByUserScreen: bulkReplaceLive/reassign');
|
||||
outcome.fail += 1;
|
||||
}
|
||||
});
|
||||
|
||||
// Фаза 3: дубли — спрашиваем по одному, накапливая тех, кого удалять.
|
||||
const toDelete: UserApprovalRow[] = [];
|
||||
for (const row of conflicting) {
|
||||
const label = `${row.requestUniqueCode || `#${row.ticketId}`} ${row.requestTitle}`;
|
||||
const choice = await askDuplicate(texts.manageByUser.duplicateLiveMessage(label, row.position, email));
|
||||
if (choice === 'skip') {
|
||||
outcome.skipped += 1;
|
||||
advanceProgress();
|
||||
} else {
|
||||
toDelete.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
const removed = await mapWithLimit(toDelete, row => requestManage.removeRow(row, row.id), {
|
||||
onSettled: advanceProgress
|
||||
});
|
||||
removed.forEach(result => {
|
||||
if (result.ok) outcome.deleted += 1;
|
||||
else {
|
||||
reportError(result.error, 'ManageByUserScreen: bulkReplaceLive/remove');
|
||||
outcome.fail += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* Замена в шаблонах маршрутов — та же трёхфазная схема, что и для живых
|
||||
* заявок, но дубль здесь ловит СЕРВЕР (HasDuplicateAsync в
|
||||
* TemplateRouteStepService → 409): своей предварительной проверки не делаем,
|
||||
* его правило шире email'а (совпадать должны ещё статус и условие появления),
|
||||
* и дублировать это на клиенте значило бы разъехаться с ним при первой же
|
||||
* правке. Поэтому «проверка» и есть сам PATCH — он идёт параллельно, а
|
||||
* разбираем 409-ые уже после.
|
||||
*/
|
||||
async function bulkReplaceTemplates(rows: UserTemplateStepRow[], email: string): Promise<IBulkOutcome> {
|
||||
const outcome: IBulkOutcome = { ok: 0, fail: 0, deleted: 0, skipped: 0 };
|
||||
|
||||
const patched = await mapWithLimit(
|
||||
rows,
|
||||
row => api.templateRouteSteps.patch(row.routeId, row.id, { executorEmail: email }),
|
||||
{ onSettled: advanceProgress }
|
||||
);
|
||||
|
||||
const conflicting: UserTemplateStepRow[] = [];
|
||||
patched.forEach((result, i) => {
|
||||
if (result.ok) {
|
||||
outcome.ok += 1;
|
||||
return;
|
||||
}
|
||||
if ((result.error as ApiClientError)?.status === 409) {
|
||||
conflicting.push(rows[i]);
|
||||
return;
|
||||
}
|
||||
reportError(result.error, 'ManageByUserScreen: bulkReplaceTemplates');
|
||||
outcome.fail += 1;
|
||||
});
|
||||
|
||||
const toDelete: UserTemplateStepRow[] = [];
|
||||
for (const row of conflicting) {
|
||||
const label = `${row.requestTypeName} — ${row.companyName}`;
|
||||
const choice = await askDuplicate(texts.manageByUser.duplicateTemplateMessage(label, row.orderNo, email));
|
||||
if (choice === 'skip') outcome.skipped += 1;
|
||||
else toDelete.push(row);
|
||||
}
|
||||
|
||||
const removed = await mapWithLimit(toDelete, row => api.templateRouteSteps.remove(row.routeId, row.id));
|
||||
removed.forEach(result => {
|
||||
if (result.ok) outcome.deleted += 1;
|
||||
else {
|
||||
reportError(result.error, 'ManageByUserScreen: bulkReplaceTemplates/remove');
|
||||
outcome.fail += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return outcome;
|
||||
}
|
||||
|
||||
const runReplace = async (scope: IReplaceScope): Promise<void> => {
|
||||
const total = scope.live.length + scope.templates.length;
|
||||
if (!targetEmail || total === 0 || progress) return;
|
||||
|
||||
const position = findEmployeeByEmail(employees.items, targetEmail)?.position || '';
|
||||
const messages: string[] = [];
|
||||
// Политика «…для всех» живёт ровно одну операцию — иначе следующая замена
|
||||
// молча унаследовала бы решение, принятое для совсем другого списка строк.
|
||||
duplicatePolicy.current = undefined;
|
||||
setProgress({ done: 0, total });
|
||||
|
||||
try {
|
||||
if (scope.live.length > 0) {
|
||||
const r = await bulkReplaceLive(scope.live, targetEmail, position);
|
||||
messages.push(texts.manageByUser.liveResult(r.ok, r.fail, r.deleted, r.skipped));
|
||||
}
|
||||
if (scope.templates.length > 0) {
|
||||
const r = await bulkReplaceTemplates(scope.templates, targetEmail);
|
||||
messages.push(texts.manageByUser.templatesResult(r.ok, r.fail, r.deleted, r.skipped));
|
||||
}
|
||||
} finally {
|
||||
// Снимать блокировку обязательно в finally: если что-то бросит мимо
|
||||
// внутренних try/catch, подложка иначе осталась бы висеть навсегда и
|
||||
// экран стал бы неработоспособен до перезагрузки страницы.
|
||||
setProgress(undefined);
|
||||
duplicatePolicy.current = undefined;
|
||||
}
|
||||
|
||||
setResultToast({ message: messages.join(' '), kind: 'ok' });
|
||||
setCheckedLiveKeys(new Set());
|
||||
setCheckedTemplateIds(new Set());
|
||||
reloadLiveRows();
|
||||
templateSteps.reload();
|
||||
};
|
||||
|
||||
// Сам себя заменить нельзя — выбор бессмысленный (и в живых заявках сразу
|
||||
// упёрся бы в проверку дубля), поэтому исходного сотрудника в списке
|
||||
// получателей не показываем вовсе.
|
||||
const targetOptions = employees.items
|
||||
.filter(e => e.email.toLowerCase() !== employeeEmail.toLowerCase())
|
||||
.map(toEmployeeOption);
|
||||
// Пока получатель не выбран, менять нечего — все кнопки замены выключены.
|
||||
const canReplace = !!employeeEmail && !!targetEmail;
|
||||
|
||||
const liveColumns: IColumn<UserApprovalRow>[] = [
|
||||
{ key: 'check', header: '', width: '32px', render: r => <Checkbox label="" checked={checkedLiveKeys.has(liveRowKey(r))} onChange={() => toggleLive(r)} /> },
|
||||
{ key: 'type', header: texts.manageByUser.columnRequestType, render: r => r.requestTypeName },
|
||||
{
|
||||
key: 'request',
|
||||
header: texts.manageByUser.columnRequest,
|
||||
render: r => <PrimaryCell>{`${r.requestUniqueCode || `#${r.ticketId}`} ${r.requestTitle}`}</PrimaryCell>
|
||||
},
|
||||
{ key: 'position', header: texts.manageByUser.columnPosition, render: r => r.position || texts.common.empty },
|
||||
{ key: 'status', header: texts.manageByUser.columnStatus, render: r => <Badge variant={statusVariant(r.status)}>{r.status}</Badge> },
|
||||
{ key: 'phase', header: texts.manageByUser.columnPhase, render: r => r.phase },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
sticky: true,
|
||||
render: r => (
|
||||
<ActionsCell>
|
||||
<IconButton icon="edit" label={texts.manageByUser.replaceOne} disabled={!canReplace} onClick={() => runReplace({ live: [r], templates: [] })} />
|
||||
</ActionsCell>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const templateColumns: IColumn<UserTemplateStepRow>[] = [
|
||||
{ key: 'check', header: '', width: '32px', render: r => <Checkbox label="" checked={checkedTemplateIds.has(r.id)} onChange={() => toggleTemplate(r)} /> },
|
||||
{
|
||||
key: 'route',
|
||||
header: texts.manageByUser.columnRoute,
|
||||
render: r => <PrimaryCell>{`${r.requestTypeName} — ${r.companyName} / ${r.departmentName}`}</PrimaryCell>
|
||||
},
|
||||
{ key: 'stepStatus', header: texts.manageByUser.columnStepStatus, render: r => routeStepStatusLabels[r.status] },
|
||||
{ key: 'condition', header: texts.manageByUser.columnCondition, render: r => describeStepCondition(r) ?? texts.common.empty },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
sticky: true,
|
||||
render: r => (
|
||||
<ActionsCell>
|
||||
<IconButton icon="edit" label={texts.manageByUser.replaceOne} disabled={!canReplace} onClick={() => runReplace({ live: [], templates: [r] })} />
|
||||
</ActionsCell>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const hasAnyRows = liveRows.length > 0 || templateRows.length > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.manageByUser.breadcrumb }]} />
|
||||
<PageHeader
|
||||
icon="people"
|
||||
subtitle={texts.manageByUser.subtitle}
|
||||
title={texts.manageByUser.title}
|
||||
actions={
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={!canReplace || !hasAnyRows}
|
||||
onClick={() => runReplace({ live: liveRows, templates: templateRows })}
|
||||
>
|
||||
{texts.manageByUser.replaceEverywhere}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Panel>
|
||||
<FormGrid>
|
||||
<Field label={texts.manageByUser.employeeLabel} required hint={texts.manageByUser.employeeHint}>
|
||||
<SearchableSelect
|
||||
options={employees.items.map(toEmployeeOption)}
|
||||
value={employeeEmail}
|
||||
loading={employees.isLoading}
|
||||
placeholder={texts.manageByUser.employeePlaceholder}
|
||||
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||
emptyMessage={texts.select.employeesEmpty}
|
||||
onChange={selectEmployee}
|
||||
/>
|
||||
</Field>
|
||||
{/* Кого ставим вместо — рядом, а не в модалке по кнопке: замена обычно
|
||||
идёт в несколько приёмов (сначала заявки, потом маршруты, потом
|
||||
отдельные строки), и выбирать одного и того же человека заново
|
||||
на каждый приём незачем. */}
|
||||
<Field label={texts.manageByUser.targetLabel} required hint={texts.manageByUser.targetHint}>
|
||||
<div className={styles.targetRow}>
|
||||
<div className={styles.targetSelect}>
|
||||
<SearchableSelect
|
||||
options={targetOptions}
|
||||
value={targetEmail}
|
||||
loading={employees.isLoading}
|
||||
placeholder={texts.manageByUser.targetPlaceholder}
|
||||
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||
emptyMessage={texts.select.employeesEmpty}
|
||||
allowClear
|
||||
clearLabel={texts.manageByUser.targetClear}
|
||||
onChange={selectTarget}
|
||||
/>
|
||||
</div>
|
||||
{/* Обратная замена — обычное продолжение работы («вернуть всё
|
||||
как было», «а теперь наоборот»), и переставлять двух человек
|
||||
руками через два поиска по справочнику ради этого не нужно. */}
|
||||
<IconButton
|
||||
icon="sort"
|
||||
label={texts.manageByUser.swap}
|
||||
disabled={!employeeEmail && !targetEmail}
|
||||
onClick={swapEmployees}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
</FormGrid>
|
||||
</Panel>
|
||||
|
||||
{employeeEmail && (
|
||||
<Panel>
|
||||
<div className={styles.panelHeader}>
|
||||
<div className={styles.panelTitle}>{`${texts.manageByUser.liveTitle} (${liveRows.length})`}</div>
|
||||
<div className={styles.panelActions}>
|
||||
<Button variant="ghost" size="sm" disabled={liveRows.length === 0} onClick={toggleAllLive}>
|
||||
{allLiveChecked ? texts.manageByUser.deselectAll : texts.manageByUser.selectAll}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={!canReplace || checkedLiveRows.length === 0}
|
||||
onClick={() => runReplace({ live: checkedLiveRows, templates: [] })}
|
||||
>
|
||||
{texts.manageByUser.replaceSelected(checkedLiveRows.length)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{liveError && <Alert kind="err">{`${texts.manageByUser.liveLoadError}: ${liveError}`}</Alert>}
|
||||
{liveLoading && <div>{texts.common.loading}</div>}
|
||||
{!liveLoading && !liveError && (
|
||||
<DataTable columns={liveColumns} rows={liveRows} keyOf={liveRowKey} emptyMessage={texts.manageByUser.liveEmpty} />
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{employeeEmail && (
|
||||
<Panel>
|
||||
<div className={styles.panelHeader}>
|
||||
<div className={styles.panelTitle}>{`${texts.manageByUser.templatesTitle} (${templateRows.length})`}</div>
|
||||
<div className={styles.panelActions}>
|
||||
<Button variant="ghost" size="sm" disabled={templateRows.length === 0} onClick={toggleAllTemplates}>
|
||||
{allTemplatesChecked ? texts.manageByUser.deselectAll : texts.manageByUser.selectAll}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={!canReplace || checkedTemplateRows.length === 0}
|
||||
onClick={() => runReplace({ live: [], templates: checkedTemplateRows })}
|
||||
>
|
||||
{texts.manageByUser.replaceSelected(checkedTemplateRows.length)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{templateSteps.error && <Alert kind="err">{`${texts.manageByUser.templatesLoadError}: ${templateSteps.error}`}</Alert>}
|
||||
{templateSteps.isLoading && <div>{texts.common.loading}</div>}
|
||||
{!templateSteps.isLoading && !templateSteps.error && (
|
||||
<DataTable columns={templateColumns} rows={templateRows} keyOf={r => r.id} emptyMessage={texts.manageByUser.templatesEmpty} />
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{progress && (
|
||||
<ProgressOverlay label={texts.manageByUser.progressLabel} done={progress.done} total={progress.total} />
|
||||
)}
|
||||
|
||||
{duplicatePrompt && (
|
||||
<ManageByUserDuplicateModal
|
||||
title={texts.manageByUser.duplicateModalTitle}
|
||||
message={duplicatePrompt.message}
|
||||
onChoice={answerDuplicate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{resultToast && (
|
||||
<Toast message={resultToast.message} kind={resultToast.kind} onClose={() => setResultToast(undefined)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '../../models';
|
||||
import { texts } from '../../texts';
|
||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { useRequestTypeListPartitions } from '../../hooks/useRequestTypeListPartitions';
|
||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
import { findEmployeeByEmail } from '../../utils/employee';
|
||||
@@ -47,6 +48,14 @@ export interface IManageRequestsScreenProps {
|
||||
route?: IManageRequestRouteParams;
|
||||
}
|
||||
|
||||
// Подпись заявки в комбобоксе — уникальный код (RequestUniqueCode, тот же, что
|
||||
// видит заявитель на портале), а не служебный числовой Id списка SharePoint.
|
||||
// Запасной вариант на #id — для заявок без заполненного кода (старые заявки,
|
||||
// либо список данных этой колонки не заводит).
|
||||
function requestLabel(r: Pick<ManagedRequestSummary, 'id' | 'title' | 'uniqueCode'>): string {
|
||||
return `${r.uniqueCode || `#${r.id}`} ${r.title}`;
|
||||
}
|
||||
|
||||
// Цвет плашки статуса — те же группы смысла, что и в request-portal
|
||||
// (StatusBadge.tsx): решено положительно/отрицательно/ожидает/черновик.
|
||||
function statusVariant(status: string): BadgeVariant {
|
||||
@@ -158,7 +167,19 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
if (!route) return;
|
||||
setSelection({
|
||||
requestTypeId: route.requestTypeId,
|
||||
selectedRequest: { id: Number(route.requestId), title: '', status: '', created: '', authorName: '' }
|
||||
// listId из ссылки — какая партиция, если ссылку скопировали уже после
|
||||
// этого фикса; пусто у старых ссылок — тогда reloadMatrix ниже
|
||||
// подставит текущую партицию (тот же эффект, что был раньше, но теперь
|
||||
// явный, а не единственно возможный).
|
||||
selectedRequest: {
|
||||
id: Number(route.requestId),
|
||||
title: '',
|
||||
status: '',
|
||||
created: '',
|
||||
authorName: '',
|
||||
uniqueCode: null,
|
||||
listId: route.listId
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -167,11 +188,15 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
// прислать коллеге. replaceRoute, а не navigate: смена заявки — это правка
|
||||
// состояния текущего экрана, а не переход на новую страницу с отдельным
|
||||
// шагом в истории браузера. title/status заявки в URL не пишем — их выводит
|
||||
// сам requestId, хранить производные данные в ссылке незачем.
|
||||
// сам requestId, хранить производные данные в ссылке незачем. listId —
|
||||
// пишем: без него по ссылке нельзя однозначно понять, в какой партиции
|
||||
// искать requestId (см. IManageRequestRouteParams).
|
||||
React.useEffect(() => {
|
||||
replaceRoute({
|
||||
tab: 'manageRequests',
|
||||
manage: selectedRequest ? { requestTypeId, requestId: String(selectedRequest.id) } : undefined
|
||||
manage: selectedRequest
|
||||
? { requestTypeId, requestId: String(selectedRequest.id), listId: selectedRequest.listId }
|
||||
: undefined
|
||||
});
|
||||
}, [requestTypeId, selectedRequest]);
|
||||
const [matrix, setMatrix] = React.useState<ManagedRequestMatrix | undefined>();
|
||||
@@ -192,6 +217,11 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
// и читать заявки этого типа отсюда просто негде.
|
||||
const manageableRequestTypes = requestTypes.items.filter(rt => rt.listId && rt.siteUrl);
|
||||
|
||||
// Вся серия партиций выбранного типа (архив + текущая) — заявки этого типа
|
||||
// физически лежат в любой из них (см. ТЗ по архивации), искать и открывать
|
||||
// нужно по всем сразу, не только по текущей.
|
||||
const partitions = useRequestTypeListPartitions(api, requestType?.id);
|
||||
|
||||
const selectRequestType = (id: string): void => {
|
||||
// Заявка выбрана для ДРУГОГО типа — сбрасываем, иначе комбобокс продолжит
|
||||
// показывать заявку не того типа, что выбран сейчас.
|
||||
@@ -205,10 +235,12 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
|
||||
const searchRequests = React.useCallback(
|
||||
(query: string, page: number) => {
|
||||
if (!requestType) return Promise.resolve({ items: [], hasMore: false });
|
||||
return requestManage.searchRequests(requestType, query, page);
|
||||
if (!requestType || !requestType.siteUrl || partitions.items.length === 0) {
|
||||
return Promise.resolve({ items: [], hasMore: false });
|
||||
}
|
||||
return requestManage.searchRequests(partitions.items, requestType.siteUrl, query, page);
|
||||
},
|
||||
[requestManage, requestType]
|
||||
[requestManage, requestType, partitions.items]
|
||||
);
|
||||
|
||||
// silent=true — фоновое обновление (после своего же действия, автообновление
|
||||
@@ -219,18 +251,38 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
const reloadMatrix = React.useCallback(
|
||||
(silent = false) => {
|
||||
if (!requestType || !selectedRequest) return;
|
||||
// Партиции ещё не подтянулись (первый рендер после смены типа/захода по
|
||||
// ссылке) — без них нельзя даже подставить запасной вариант (текущая
|
||||
// партиция) для ссылок без listId, ждём следующего рендера.
|
||||
if (partitions.isLoading) return;
|
||||
if (!requestType.siteUrl) {
|
||||
setMatrixError('У типа заявки не указан список SharePoint.');
|
||||
return;
|
||||
}
|
||||
// listId заявки уже известен (выбрана из комбобокса, либо ссылка после
|
||||
// этого фикса) — используем его. Иначе (старая ссылка без listId) —
|
||||
// единственный разумный запасной вариант: текущая партиция, тот же
|
||||
// список, что искался бы и раньше.
|
||||
const listId = selectedRequest.listId || partitions.items.filter(p => p.isCurrent)[0]?.listId;
|
||||
if (!listId) {
|
||||
setMatrixError('Для типа заявки не найдено ни одной партиции списка SharePoint.');
|
||||
return;
|
||||
}
|
||||
if (!silent) setMatrixLoading(true);
|
||||
setMatrixError(undefined);
|
||||
requestManage
|
||||
.getMatrix(requestType, selectedRequest.id)
|
||||
.getMatrix(requestType.siteUrl, listId, selectedRequest.id)
|
||||
.then(m => {
|
||||
setMatrix(m);
|
||||
// title/status в комбобоксе (а значит, и в localStorage) держим
|
||||
// title/status/listId в комбобоксе (а значит, и в localStorage) держим
|
||||
// синхронными с сервером — особенно важно после захода по глубокой
|
||||
// ссылке, где изначально они пустые (см. эффект-затравку выше).
|
||||
// ссылке, где изначально они пустые (см. эффект-затравку выше), и
|
||||
// после подстановки запасной (текущей) партиции выше: как только
|
||||
// реальная партиция заявки известна, оседает в выборе — следующий
|
||||
// reloadMatrix пойдёт уже по ней напрямую.
|
||||
//
|
||||
// ВАЖНО: возвращаем ТОТ ЖЕ prev (без изменений), если title/status не
|
||||
// отличаются — m.request это каждый раз НОВЫЙ объект (свежий парсинг
|
||||
// ВАЖНО: возвращаем ТОТ ЖЕ prev (без изменений), если ничего не
|
||||
// отличается — m.request это каждый раз НОВЫЙ объект (свежий парсинг
|
||||
// ответа), и если писать его в состояние безусловно, selectedRequest
|
||||
// получает новую ссылку на КАЖДОЙ загрузке, даже когда содержимое не
|
||||
// поменялось. Это меняет зависимости reloadMatrix → пересоздаёт саму
|
||||
@@ -240,7 +292,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
setSelection(prev => {
|
||||
const sel = prev.selectedRequest;
|
||||
if (!sel || sel.id !== m.request.id) return prev;
|
||||
const same = sel.title === m.request.title && sel.status === m.request.status;
|
||||
const same = sel.title === m.request.title && sel.status === m.request.status && sel.listId === m.request.listId;
|
||||
return same ? prev : { ...prev, selectedRequest: m.request };
|
||||
});
|
||||
})
|
||||
@@ -249,7 +301,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
if (!silent) setMatrixLoading(false);
|
||||
});
|
||||
},
|
||||
[requestManage, requestType, selectedRequest]
|
||||
[requestManage, requestType, selectedRequest, partitions.items, partitions.isLoading]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -533,14 +585,14 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
<AsyncSearchableSelect<ManagedRequestSummary>
|
||||
key={requestTypeId || 'none'}
|
||||
value={selectedRequest ? String(selectedRequest.id) : ''}
|
||||
selectedLabel={selectedRequest ? `#${selectedRequest.id} ${selectedRequest.title}` : undefined}
|
||||
selectedLabel={selectedRequest ? requestLabel(selectedRequest) : undefined}
|
||||
selectedSublabel={selectedRequest?.status}
|
||||
disabled={!requestType}
|
||||
placeholder={texts.manageRequests.requestPlaceholder}
|
||||
searchPlaceholder={texts.manageRequests.requestSearchPlaceholder}
|
||||
search={searchRequests}
|
||||
getValue={r => String(r.id)}
|
||||
getLabel={r => `#${r.id} ${r.title}`}
|
||||
getLabel={requestLabel}
|
||||
getSublabel={r => r.status}
|
||||
onChange={(_, item) => selectRequest(item)}
|
||||
/>
|
||||
|
||||
@@ -50,8 +50,14 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
// wrap обязателен, а не косметика: кнопки .btn — white-space: nowrap, сжаться по
|
||||
// тексту не могут, а justify-content: flex-end выталкивает не влезающие ВЛЕВО,
|
||||
// за границу .box — и они просто исчезают из виду (так пропадала кнопка «Нет,
|
||||
// пропустить» в диалоге на четыре варианта, см. ManageByUserDuplicateModal).
|
||||
// С переносом лишние кнопки уезжают на вторую строку и остаются доступны.
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 16px 26px 22px;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
// Ниже Modal.module.scss/.overlay (z-index 300) — диалоги, которые может
|
||||
// открыть сама длительная операция (например, разрешение конфликта), должны
|
||||
// быть ПОВЕРХ этой подложки, а не под ней.
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 250;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(10, 27, 48, .45);
|
||||
}
|
||||
|
||||
.box {
|
||||
min-width: 320px;
|
||||
max-width: 420px;
|
||||
padding: 24px 26px;
|
||||
border-radius: $r-lg;
|
||||
background: #fff;
|
||||
box-shadow: $sh-modal;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: $gray-900;
|
||||
}
|
||||
|
||||
.counter {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.track {
|
||||
margin-top: 14px;
|
||||
height: 6px;
|
||||
border-radius: $r-full;
|
||||
background: $gray-200;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fill {
|
||||
height: 100%;
|
||||
border-radius: $r-full;
|
||||
background: $grad-gold;
|
||||
transition: width .2s;
|
||||
}
|
||||
|
||||
// Общее число шагов заранее неизвестно — бегущая полоса вместо доли.
|
||||
.indeterminate {
|
||||
width: 40%;
|
||||
animation: slide 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes slide {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(250%); }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from 'react';
|
||||
import styles from './ProgressOverlay.module.scss';
|
||||
|
||||
export interface IProgressOverlayProps {
|
||||
label: string;
|
||||
/** Сколько шагов уже сделано. */
|
||||
done: number;
|
||||
/** Всего шагов. 0/undefined — полоса «бежит», доля не показывается. */
|
||||
total?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Модальная подложка с полосой прогресса на время длительной пакетной операции:
|
||||
* показывает, что работа идёт и сколько осталось, и заодно физически не даёт
|
||||
* кликать по интерфейсу под ней (иначе можно запустить вторую замену поверх
|
||||
* первой или уйти на другую вкладку посреди записи).
|
||||
*
|
||||
* Закрыть её нельзя намеренно — операция уже пишет в SharePoint/БД, и «отмена»
|
||||
* оставила бы часть строк заменённой, а часть нет, без всякого следа об этом.
|
||||
*/
|
||||
export const ProgressOverlay: React.FC<IProgressOverlayProps> = ({ label, done, total }) => {
|
||||
const percent = total && total > 0 ? Math.min(100, Math.round((done / total) * 100)) : undefined;
|
||||
|
||||
return (
|
||||
<div className={styles.overlay} role="alert" aria-busy="true">
|
||||
<div className={styles.box}>
|
||||
<div className={styles.label}>{label}</div>
|
||||
{total ? <div className={styles.counter}>{`${done} / ${total}`}</div> : undefined}
|
||||
<div className={styles.track}>
|
||||
<div
|
||||
className={`${styles.fill} ${percent === undefined ? styles.indeterminate : ''}`}
|
||||
style={percent === undefined ? undefined : { width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,7 @@ export * from './IconButton';
|
||||
export * from './Badge';
|
||||
export * from './Alert';
|
||||
export * from './Toast';
|
||||
export * from './ProgressOverlay';
|
||||
export * from './Panel';
|
||||
export * from './Breadcrumbs';
|
||||
export * from './PageHeader';
|
||||
|
||||
Reference in New Issue
Block a user