Панель со списками заявок #1
@@ -0,0 +1,83 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
// Выезжающая панель поверх страницы. position: fixed — панель должна перекрывать
|
||||
// всю страницу SharePoint, а не только канву web part.
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(10, 27, 48, .45);
|
||||
animation: fade .2s ease-out;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(920px, 100%);
|
||||
height: 100%;
|
||||
background: $gray-50;
|
||||
box-shadow: $sh-modal;
|
||||
animation: slide .22s ease-out;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $sp-4;
|
||||
flex-shrink: 0;
|
||||
padding: $sp-4 $sp-6;
|
||||
background: $white;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: $font-serif;
|
||||
font-size: 18px;
|
||||
color: $navy-900;
|
||||
}
|
||||
|
||||
.close {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $r-sm;
|
||||
background: $white;
|
||||
color: $navy-700;
|
||||
cursor: pointer;
|
||||
transition: .16s;
|
||||
|
||||
&:hover {
|
||||
border-color: $red-500;
|
||||
color: $red-500;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: $sp-6;
|
||||
}
|
||||
|
||||
@keyframes slide {
|
||||
from { transform: translateX(24px); opacity: .6; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Drawer.module.scss';
|
||||
import { Icon } from '../../shared/ui/Icon/Icon';
|
||||
|
||||
interface IDrawerProps {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// Выезжающая справа панель: открывается по клику на строку таблицы заявок,
|
||||
// внутри — заявка в режиме просмотра/согласования. Закрывается крестиком,
|
||||
// кликом по затемнению и клавишей Esc.
|
||||
export const Drawer: React.FC<IDrawerProps> = ({ title, onClose, children }) => {
|
||||
React.useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className={styles.overlay}>
|
||||
{/* Затемнение — не кнопка (внутри панели свои интерактивные элементы),
|
||||
поэтому клик обрабатываем на этом слое напрямую. */}
|
||||
<div className={styles.backdrop} onClick={onClose} aria-hidden="true" />
|
||||
<aside className={styles.drawer} role="dialog" aria-modal="true" aria-label={title}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.title}>{title}</span>
|
||||
<button type="button" className={styles.close} onClick={onClose} aria-label="Закрыть">
|
||||
<Icon name="close" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.body}>{children}</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -32,13 +32,13 @@
|
||||
font-family: $font-sans;
|
||||
}
|
||||
|
||||
// Двухколоночная раскладка: формы (main) + постоянная панель (side).
|
||||
// Раскладка: боковое меню (side) + основная область (main).
|
||||
.layout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: $sp-6;
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -47,50 +47,27 @@
|
||||
min-width: 0; // чтобы контент не распирал колонку
|
||||
}
|
||||
|
||||
// Ширину задаёт само меню (SideNav сворачивается) — колонка просто под него.
|
||||
.side {
|
||||
flex: 0 0 380px;
|
||||
flex: 0 0 auto;
|
||||
position: sticky;
|
||||
top: $sp-4;
|
||||
align-self: stretch;
|
||||
transition: flex-basis .2s;
|
||||
}
|
||||
|
||||
.sideCollapsed {
|
||||
flex-basis: 44px;
|
||||
}
|
||||
|
||||
// Та же ширина, что у контента подачи (SubmitFeature .content) — чтобы вкладки
|
||||
// не уезжали шире формы под ними.
|
||||
.tabsWrap {
|
||||
max-width: 1080px;
|
||||
margin-bottom: $sp-6;
|
||||
}
|
||||
|
||||
// Возврат из режима согласования (approve) к списку заявок (SubmitFeature).
|
||||
.backButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
margin-bottom: $sp-4;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $navy-700;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-sm;
|
||||
cursor: pointer;
|
||||
transition: color .18s;
|
||||
|
||||
&:hover {
|
||||
color: $gold-600;
|
||||
}
|
||||
}
|
||||
|
||||
// На узких экранах панель уходит под формы.
|
||||
// На узких экранах меню уходит наверх, в одну строку.
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.side {
|
||||
flex-basis: auto;
|
||||
width: 100%;
|
||||
position: static;
|
||||
}
|
||||
|
||||
@@ -2,28 +2,33 @@ import * as React from 'react';
|
||||
import styles from './RequestPortal.module.scss';
|
||||
import type { IRequestPortalProps } from './IRequestPortalProps';
|
||||
import { IconSprite } from '../shared/ui/Icon/IconSprite';
|
||||
import { Icon } from '../shared/ui/Icon/Icon';
|
||||
import { navigate, useRoute } from '../shared/routing';
|
||||
import { navigate, useRoute, NavSection } from '../shared/routing';
|
||||
import { onRequestsChanged } from '../shared/requestEvents';
|
||||
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 MyRequestsPanel from '../features/myRequests/MyRequestsPanel';
|
||||
import { RequestsTableView } from '../features/myRequests/RequestsTableView';
|
||||
import { getPendingApproval } from '../features/myRequests/myRequests.service';
|
||||
|
||||
// Оболочка портала. Логики фич тут нет — файл общий, меняется редко.
|
||||
// Роутинг по URL: mode=approve&type=&id= → фича согласования, иначе — подача.
|
||||
// Оболочка портала: тёмное боковое меню (разделы) + основная область.
|
||||
// Логики фич тут нет — файл общий, меняется редко.
|
||||
//
|
||||
// Вкладки категорий рендерятся здесь (а не внутри SubmitFeature), чтобы
|
||||
// оставаться на экране и в режиме согласования (approve) — раньше режим
|
||||
// согласования подменял всю область main и вкладки пропадали вместе с ней.
|
||||
// Разделы (rpnav): 'new' — подача заявки (вкладки категорий + плитки + форма),
|
||||
// 'approvals'/'own' — таблицы заявок. Клик по строке таблицы открывает заявку
|
||||
// в выезжающей панели ПОВЕРХ раздела (rpmode=approve&rptype=&rpid=), поэтому
|
||||
// раздел под ней сохраняется; та же ссылка приходит из потока Power Automate.
|
||||
const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context }) => {
|
||||
const rootClasses = [styles.requestPortal, hasTeamsContext ? styles.teams : '']
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const route = useRoute();
|
||||
const [panelCollapsed, setPanelCollapsed] = React.useState<boolean>(false);
|
||||
const [navCollapsed, setNavCollapsed] = React.useState<boolean>(false);
|
||||
const [approvalsCount, setApprovalsCount] = React.useState<number | undefined>(undefined);
|
||||
|
||||
const [activeCategoryId, setActiveCategoryId] = React.useState<string>(() => {
|
||||
if (route.mode === 'create' && route.formKey) {
|
||||
@@ -33,70 +38,88 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
return DEFAULT_CATEGORY_ID;
|
||||
});
|
||||
|
||||
// Клик по заявке в «Текущие заявки» (или ссылка «создать на основе») — переключает
|
||||
// вкладку категории на ту, к которой относится тип этой заявки, чтобы вкладки
|
||||
// сверху совпадали с тем, что сейчас открыто в main.
|
||||
// Счётчик на пункте «На согласовании». Обновляется по тому же событию, что и
|
||||
// сами таблицы (создание заявки, решение согласующего).
|
||||
React.useEffect(() => {
|
||||
if ((route.mode === 'approve' || route.mode === 'create') && route.formKey) {
|
||||
const found = REQUEST_TYPES.filter((r) => r.id === route.formKey)[0];
|
||||
if (found) setActiveCategoryId(found.categoryId);
|
||||
}
|
||||
}, [route.mode, route.formKey]);
|
||||
let cancelled = false;
|
||||
|
||||
const load = (): void => {
|
||||
getPendingApproval(context)
|
||||
.then((r) => {
|
||||
if (!cancelled) setApprovalsCount(r.rows.length);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setApprovalsCount(undefined);
|
||||
});
|
||||
};
|
||||
|
||||
load();
|
||||
const unsubscribe = onRequestsChanged(load);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [context]);
|
||||
|
||||
const goto = (nav: NavSection): void => navigate({ mode: 'submit', nav });
|
||||
const closeDrawer = (): void => navigate({ mode: 'submit', nav: route.nav });
|
||||
|
||||
return (
|
||||
<section className={rootClasses}>
|
||||
<IconSprite />
|
||||
<div className={styles.layout}>
|
||||
<div className={`${styles.side} ${panelCollapsed ? styles.sideCollapsed : ''}`}>
|
||||
<MyRequestsPanel
|
||||
context={context}
|
||||
collapsed={panelCollapsed}
|
||||
onToggleCollapsed={() => setPanelCollapsed((v) => !v)}
|
||||
<div className={styles.side}>
|
||||
<SideNav
|
||||
activeId={route.nav}
|
||||
onChange={goto}
|
||||
approvalsCount={approvalsCount}
|
||||
collapsed={navCollapsed}
|
||||
onToggleCollapsed={() => setNavCollapsed((v) => !v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<main className={styles.main}>
|
||||
<div className={styles.tabsWrap}>
|
||||
<CategoryTabs
|
||||
categories={CATEGORIES}
|
||||
activeId={activeCategoryId}
|
||||
onChange={(id) => {
|
||||
setActiveCategoryId(id);
|
||||
// Клик по вкладке всегда возвращает к списку плиток: в режиме
|
||||
// подачи это уже делает сам SubmitFeature (сброс выбранной формы
|
||||
// при смене категории), а в режиме согласования (заявка открыта
|
||||
// из «Текущие заявки») route сам не меняется — выходим явно,
|
||||
// чтобы поведение вкладок было одинаковым в обоих случаях.
|
||||
if (route.mode === 'approve') {
|
||||
navigate({ mode: 'submit' });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{route.mode === 'approve' && route.formKey && route.itemId ? (
|
||||
{route.nav === 'new' ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backButton}
|
||||
onClick={() => navigate({ mode: 'submit' })}
|
||||
>
|
||||
<Icon name="arrow-left" size="sm" />
|
||||
<span>К списку заявок</span>
|
||||
</button>
|
||||
<ApproveFeature context={context} formKey={route.formKey} itemId={route.itemId} />
|
||||
<div className={styles.tabsWrap}>
|
||||
<CategoryTabs
|
||||
categories={CATEGORIES}
|
||||
activeId={activeCategoryId}
|
||||
onChange={setActiveCategoryId}
|
||||
/>
|
||||
</div>
|
||||
<SubmitFeature
|
||||
context={context}
|
||||
activeCategoryId={activeCategoryId}
|
||||
initialForm={
|
||||
route.mode === 'create' && route.formKey && route.sourceId
|
||||
? { formKey: route.formKey, sourceId: route.sourceId }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<SubmitFeature
|
||||
<RequestsTableView
|
||||
context={context}
|
||||
activeCategoryId={activeCategoryId}
|
||||
initialForm={
|
||||
route.mode === 'create' && route.formKey && route.sourceId
|
||||
? { formKey: route.formKey, sourceId: route.sourceId }
|
||||
: undefined
|
||||
kind={route.nav === 'approvals' ? 'approvals' : 'own'}
|
||||
onOpen={(request) =>
|
||||
navigate({
|
||||
mode: 'approve',
|
||||
nav: route.nav,
|
||||
formKey: request.formKey,
|
||||
itemId: request.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
// Тёмное боковое меню портала (фирменный navy-градиент, золотой маркер
|
||||
// активного пункта — как в остальном дизайне).
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 240px;
|
||||
padding: $sp-3 0;
|
||||
background: $grad-header;
|
||||
border-radius: $r-lg;
|
||||
box-shadow: $sh-2;
|
||||
overflow: hidden;
|
||||
transition: width .2s;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
align-self: flex-end;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
margin: 0 $sp-3 $sp-2;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, .18);
|
||||
border-radius: $r-sm;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, .6);
|
||||
cursor: pointer;
|
||||
transition: .16s;
|
||||
|
||||
&:hover {
|
||||
border-color: $gold-400;
|
||||
color: $gold-300;
|
||||
}
|
||||
}
|
||||
|
||||
.navCollapsed {
|
||||
width: 56px;
|
||||
|
||||
.toggle {
|
||||
align-self: center;
|
||||
margin: 0 0 $sp-2;
|
||||
}
|
||||
|
||||
.item {
|
||||
justify-content: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-3;
|
||||
width: 100%;
|
||||
padding: 12px 18px;
|
||||
border: 0;
|
||||
border-left: 3px solid transparent;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, .62);
|
||||
font-family: $font-sans;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color .18s, background .18s, border-color .18s;
|
||||
|
||||
&:hover {
|
||||
color: rgba(255, 255, 255, .92);
|
||||
background: rgba(255, 255, 255, .04);
|
||||
}
|
||||
}
|
||||
|
||||
.itemActive {
|
||||
color: $white;
|
||||
background: rgba(255, 255, 255, .07);
|
||||
border-left-color: $gold-500;
|
||||
|
||||
.itemIcon {
|
||||
color: $gold-400;
|
||||
}
|
||||
}
|
||||
|
||||
.itemIcon {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.itemTitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex-shrink: 0;
|
||||
min-width: 18px;
|
||||
padding: 1px 6px;
|
||||
border-radius: $r-full;
|
||||
background: $gold-500;
|
||||
color: $navy-950;
|
||||
font-size: $fs-overline;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// В свёрнутом меню бейдж «прилипает» к иконке, т.к. подписи нет.
|
||||
.navCollapsed .badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 8px;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as React from 'react';
|
||||
import styles from './SideNav.module.scss';
|
||||
import { Icon } from '../../shared/ui/Icon/Icon';
|
||||
import { IconName } from '../../shared/ui/Icon/iconNames';
|
||||
import { NavSection } from '../../shared/routing';
|
||||
|
||||
interface INavItem {
|
||||
id: NavSection;
|
||||
title: string;
|
||||
icon: IconName;
|
||||
}
|
||||
|
||||
const ITEMS: INavItem[] = [
|
||||
{ id: 'new', title: 'Новая заявка', icon: 'plus' },
|
||||
{ id: 'approvals', title: 'На согласовании', icon: 'user-check' },
|
||||
{ id: 'own', title: 'Мои заявки', icon: 'clipboard' },
|
||||
];
|
||||
|
||||
interface ISideNavProps {
|
||||
activeId: NavSection;
|
||||
onChange: (id: NavSection) => void;
|
||||
/** Счётчик у пункта «На согласовании» (сколько ждут решения). */
|
||||
approvalsCount?: number;
|
||||
collapsed: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
}
|
||||
|
||||
// Тёмное боковое меню портала: переключает разделы (подача заявки / таблицы
|
||||
// заявок). Сворачивается до узкой полосы с одними иконками.
|
||||
export const SideNav: React.FC<ISideNavProps> = ({
|
||||
activeId,
|
||||
onChange,
|
||||
approvalsCount,
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
}) => {
|
||||
const navClasses = [styles.nav, collapsed ? styles.navCollapsed : ''].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<nav className={navClasses} aria-label="Разделы портала">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggle}
|
||||
onClick={onToggleCollapsed}
|
||||
aria-label={collapsed ? 'Развернуть меню' : 'Свернуть меню'}
|
||||
title={collapsed ? 'Развернуть меню' : 'Свернуть меню'}
|
||||
>
|
||||
<Icon name={collapsed ? 'chevron-right' : 'chevron-left'} size="sm" />
|
||||
</button>
|
||||
|
||||
{ITEMS.map((item) => {
|
||||
const isActive = item.id === activeId;
|
||||
const itemClasses = [styles.item, isActive ? styles.itemActive : ''].filter(Boolean).join(' ');
|
||||
const showCount = item.id === 'approvals' && !!approvalsCount;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={itemClasses}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
onClick={() => onChange(item.id)}
|
||||
title={collapsed ? item.title : undefined}
|
||||
>
|
||||
<span className={styles.itemIcon}>
|
||||
<Icon name={item.icon} size="sm" />
|
||||
</span>
|
||||
{!collapsed && <span className={styles.itemTitle}>{item.title}</span>}
|
||||
{showCount && <span className={styles.badge}>{approvalsCount}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -1,384 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { getLinkedTypes, getOwnRequests, getPendingApproval } from './myRequests.service';
|
||||
import { IMyRequest } from './models/IMyRequest';
|
||||
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
|
||||
import { navigate, routeUrl } from '../../shared/routing';
|
||||
import { onRequestsChanged } from '../../shared/requestEvents';
|
||||
import { Icon } from '../../shared/ui/Icon/Icon';
|
||||
import styles from './MyRequestsPanel.module.scss';
|
||||
|
||||
export interface IMyRequestsPanelProps {
|
||||
context: WebPartContext;
|
||||
/** Свёрнута ли панель (управляется снаружи — RequestPortal меняет ширину колонки). */
|
||||
collapsed?: boolean;
|
||||
onToggleCollapsed?: () => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`;
|
||||
const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`;
|
||||
return `${dd}.${mm}.${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
function statusClass(status: string): string {
|
||||
switch (status) {
|
||||
case REQUEST_STATUS.approved:
|
||||
return styles.statusApproved;
|
||||
case REQUEST_STATUS.rejected:
|
||||
return styles.statusRejected;
|
||||
case REQUEST_STATUS.returned:
|
||||
return styles.statusReturned;
|
||||
case REQUEST_STATUS.onApproval:
|
||||
return styles.statusOnApproval;
|
||||
case REQUEST_STATUS.draft:
|
||||
return styles.statusDraft;
|
||||
default:
|
||||
return styles.statusDefault;
|
||||
}
|
||||
}
|
||||
|
||||
// «Ждёt вашего решения»: автор здесь — не вы, поэтому важна колонка «Автор»,
|
||||
// а статус не нужен (в этой секции все заявки по определению «на согласовании»).
|
||||
// «Созданы вами»: автор — всегда вы, колонку не показываем, зато нужен статус.
|
||||
const RequestRow: React.FC<{ r: IMyRequest; variant: 'own' | 'approval' }> = ({ r, variant }) => (
|
||||
<tr className={styles.row}>
|
||||
<td className={styles.cellType}>{r.requestTypeName}</td>
|
||||
<td className={styles.cellTitle}>
|
||||
<a
|
||||
className={styles.titleLink}
|
||||
href={routeUrl({ mode: 'approve', formKey: r.formKey, itemId: r.id })}
|
||||
onClick={(e) => {
|
||||
// Обычный левый клик — SPA-переход (без перезагрузки страницы).
|
||||
// Ctrl/Cmd/Shift/среднюю кнопку — не трогаем, пусть браузер сам
|
||||
// откроет в новой вкладке по href.
|
||||
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
navigate({ mode: 'approve', formKey: r.formKey, itemId: r.id });
|
||||
}}
|
||||
>
|
||||
{r.title || `#${r.id}`}
|
||||
</a>
|
||||
</td>
|
||||
<td className={styles.cellDate}>{formatDate(r.created)}</td>
|
||||
{variant === 'approval' && (
|
||||
<td className={styles.cellAuthor}>
|
||||
{[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'}
|
||||
</td>
|
||||
)}
|
||||
{variant === 'own' && (
|
||||
<td className={styles.cellStatus}>
|
||||
<span className={`${styles.status} ${statusClass(r.status)}`}>{r.status || '—'}</span>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
|
||||
const RequestsTable: React.FC<{ rows: IMyRequest[]; variant: 'own' | 'approval' }> = ({ rows, variant }) => (
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.cellType}>Тип</th>
|
||||
<th className={styles.cellTitle}>Название</th>
|
||||
<th className={styles.cellDate}>Дата</th>
|
||||
{variant === 'approval' && <th className={styles.cellAuthor}>Автор</th>}
|
||||
{variant === 'own' && <th className={styles.cellStatus}>Статус</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<RequestRow r={r} variant={variant} key={r.id} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
// Постраничная навигация: номера страниц + вперёд/назад. При большом
|
||||
// количестве страниц показывает только соседние с текущей + первую/последнюю.
|
||||
const Pager: React.FC<{ page: number; totalPages: number; onChange: (page: number) => void }> = ({
|
||||
page,
|
||||
totalPages,
|
||||
onChange,
|
||||
}) => {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const numbers: number[] = [];
|
||||
const from = Math.max(1, page - 2);
|
||||
const to = Math.min(totalPages, page + 2);
|
||||
for (let i = from; i <= to; i++) numbers.push(i);
|
||||
|
||||
return (
|
||||
<div className={styles.pager}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pagerBtn}
|
||||
disabled={page <= 1}
|
||||
onClick={() => onChange(page - 1)}
|
||||
aria-label="Предыдущая страница"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
{from > 1 && (
|
||||
<>
|
||||
<button type="button" className={styles.pagerBtn} onClick={() => onChange(1)}>
|
||||
1
|
||||
</button>
|
||||
{from > 2 && <span className={styles.pagerEllipsis}>…</span>}
|
||||
</>
|
||||
)}
|
||||
{numbers.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`${styles.pagerBtn} ${n === page ? styles.pagerBtnActive : ''}`}
|
||||
onClick={() => onChange(n)}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
{to < totalPages && (
|
||||
<>
|
||||
{to < totalPages - 1 && <span className={styles.pagerEllipsis}>…</span>}
|
||||
<button type="button" className={styles.pagerBtn} onClick={() => onChange(totalPages)}>
|
||||
{totalPages}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pagerBtn}
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onChange(page + 1)}
|
||||
aria-label="Следующая страница"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// «Ждёт вашего решения»: данные уже загружены целиком (алгоритм требует видеть
|
||||
// весь маршрут заявки, чтобы понять, чья сейчас очередь) — один общий список
|
||||
// по всем типам, пагинация — клиентская нарезка по этому списку.
|
||||
const PendingSection: React.FC<{ rows: IMyRequest[] }> = ({ rows }) => {
|
||||
const [page, setPage] = React.useState(1);
|
||||
React.useEffect(() => setPage(1), [rows]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <div className={styles.empty}>Нет заявок, ждущих вашего решения.</div>;
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
|
||||
const pageRows = rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RequestsTable rows={pageRows} variant="approval" />
|
||||
<Pager page={page} totalPages={totalPages} onChange={setPage} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// «Созданы вами»: один общий постраничный список по всем типам сразу через
|
||||
// обычный REST (см. myRequests.service.getOwnRequests) — единая пагинация,
|
||||
// без разбивки по типам, данные всегда актуальные (не из поискового индекса),
|
||||
// поэтому секция перечитывается и по событию rp-requests-changed.
|
||||
const OwnSection: React.FC<{ context: WebPartContext; types: IRequestType[]; query: string }> = ({
|
||||
context,
|
||||
types,
|
||||
query,
|
||||
}) => {
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [rows, setRows] = React.useState<IMyRequest[]>([]);
|
||||
const [totalRows, setTotalRows] = React.useState<number | undefined>(undefined);
|
||||
const [error, setError] = React.useState<string>('');
|
||||
|
||||
React.useEffect(() => setPage(1), [query]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (types.length === 0) {
|
||||
setTotalRows(0);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
|
||||
const load = (): void => {
|
||||
getOwnRequests(context, types, query, page)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setRows(result.rows);
|
||||
setTotalRows(result.totalRows);
|
||||
setError('');
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError((e as Error).message);
|
||||
});
|
||||
};
|
||||
|
||||
load();
|
||||
const unsubscribe = onRequestsChanged(load);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [context, types, query, page]);
|
||||
|
||||
if (error) {
|
||||
return <div className={styles.error}>Не удалось загрузить: {error}</div>;
|
||||
}
|
||||
|
||||
if (totalRows === undefined) {
|
||||
return <div className={styles.empty}>Загрузка…</div>;
|
||||
}
|
||||
if (totalRows === 0) {
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
{query ? `По запросу «${query}» ничего не найдено.` : 'Вы ещё не создавали заявок.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<>
|
||||
<RequestsTable rows={rows} variant="own" />
|
||||
<Pager page={page} totalPages={totalPages} onChange={setPage} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Постоянная боковая панель «Текущие заявки»: заявки, созданные текущим
|
||||
// пользователем, и заявки, ждущие сейчас его решения как согласующего —
|
||||
// по всем типам заявок, известным approval-route API, в двух общих списках
|
||||
// без разбивки по типам (тип виден на самой карточке).
|
||||
// Владелец: второй разработчик. Вся логика/данные — внутри этой папки.
|
||||
// Общий UI-кит (shared/ui), pnpjs и config можно импортировать, но НЕ менять.
|
||||
//
|
||||
// Обе секции перечитываются по событию rp-requests-changed
|
||||
// (shared/requestEvents) — оно шлётся после сохранения формы и после решения
|
||||
// согласующего, так что список обновляется сам, без перезагрузки страницы.
|
||||
//
|
||||
// Поиск по названию: «Ждёт вашего решения» фильтруется на клиенте (данные уже
|
||||
// загружены целиком), «Созданы вами» — серверным OData-фильтром по Title.
|
||||
//
|
||||
// Диагностика (listId/siteUrl/rowCount/error) в UI не выводится —
|
||||
// см. console.log/console.table в devtools при загрузке панели.
|
||||
const MyRequestsPanel: React.FC<IMyRequestsPanelProps> = ({ context, collapsed, onToggleCollapsed }) => {
|
||||
const [linkedTypes, setLinkedTypes] = React.useState<IRequestType[] | undefined>(undefined);
|
||||
const [pendingRows, setPendingRows] = React.useState<IMyRequest[] | undefined>(undefined);
|
||||
const [error, setError] = React.useState<string>('');
|
||||
const [query, setQuery] = React.useState<string>('');
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
getLinkedTypes(context)
|
||||
.then((types) => {
|
||||
if (!cancelled) setLinkedTypes(types);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError((e as Error).message);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [context]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const load = (): void => {
|
||||
getPendingApproval(context)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setPendingRows(result.rows);
|
||||
console.table(result.debug);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError((e as Error).message);
|
||||
});
|
||||
};
|
||||
|
||||
load();
|
||||
const unsubscribe = onRequestsChanged(load);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [context]);
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const filteredPending =
|
||||
pendingRows && q ? pendingRows.filter((r) => r.title.toLowerCase().indexOf(q) !== -1) : pendingRows;
|
||||
|
||||
const nothingLoaded = linkedTypes !== undefined && pendingRows !== undefined;
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<aside className={`${styles.panel} ${styles.panelCollapsed}`} aria-label="Текущие заявки (свёрнуто)">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.collapseToggle}
|
||||
onClick={onToggleCollapsed}
|
||||
aria-label="Показать «Текущие заявки»"
|
||||
title="Показать «Текущие заявки»"
|
||||
>
|
||||
<Icon name="chevron-right" size="sm" />
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={styles.panel} aria-label="Текущие заявки">
|
||||
<div className={styles.header}>
|
||||
<span className={styles.title}>Текущие заявки</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.collapseToggle}
|
||||
onClick={onToggleCollapsed}
|
||||
aria-label="Свернуть «Текущие заявки»"
|
||||
title="Свернуть «Текущие заявки»"
|
||||
>
|
||||
<Icon name="chevron-left" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.searchWrap}>
|
||||
<input
|
||||
type="search"
|
||||
className={styles.search}
|
||||
placeholder="Поиск по названию…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
{error && <div className={styles.error}>Не удалось загрузить заявки: {error}</div>}
|
||||
{!error && !nothingLoaded && <div className={styles.empty}>Загрузка…</div>}
|
||||
|
||||
{!error && nothingLoaded && (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>Ждёт вашего решения</div>
|
||||
<PendingSection rows={filteredPending || []} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && nothingLoaded && (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>Созданы вами</div>
|
||||
<OwnSection context={context} types={linkedTypes || []} query={q} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyRequestsPanel;
|
||||
+131
-160
@@ -1,66 +1,43 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
// Постоянная боковая панель (всегда открыта). Раскладку колонок задаёт корень;
|
||||
// здесь — только внутреннее оформление панели.
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-lg;
|
||||
box-shadow: $sh-1;
|
||||
overflow: hidden;
|
||||
.view {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $sp-2;
|
||||
padding: $sp-4 $sp-5;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
gap: $sp-4;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: $sp-4;
|
||||
}
|
||||
|
||||
.collapseToggle {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $r-sm;
|
||||
background: $white;
|
||||
color: $navy-700;
|
||||
cursor: pointer;
|
||||
transition: .16s;
|
||||
|
||||
&:hover {
|
||||
border-color: $gold-400;
|
||||
color: $gold-600;
|
||||
}
|
||||
}
|
||||
|
||||
.panelCollapsed {
|
||||
align-items: center;
|
||||
padding: $sp-3 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
gap: $sp-3;
|
||||
margin: 0;
|
||||
font-family: $font-serif;
|
||||
font-size: 18px;
|
||||
font-size: $fs-page-title;
|
||||
font-weight: 600;
|
||||
color: $navy-900;
|
||||
}
|
||||
|
||||
.searchWrap {
|
||||
padding: $sp-3 $sp-5 0;
|
||||
.count {
|
||||
padding: 2px 10px;
|
||||
border-radius: $r-full;
|
||||
background: $navy-100;
|
||||
color: $navy-700;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-label;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
width: 280px;
|
||||
max-width: 100%;
|
||||
padding: 9px 13px;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $r-sm;
|
||||
background: $white;
|
||||
@@ -76,166 +53,111 @@
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: $sp-5;
|
||||
font-size: $fs-sm;
|
||||
}
|
||||
|
||||
.empty,
|
||||
.error {
|
||||
padding: $sp-6;
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-md;
|
||||
color: $gray-500;
|
||||
font-size: $fs-sm;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: $red-500;
|
||||
border-color: $red-500;
|
||||
background: $red-50;
|
||||
}
|
||||
|
||||
.section + .section {
|
||||
margin-top: $sp-6;
|
||||
padding-top: $sp-6;
|
||||
border-top: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-caption;
|
||||
font-weight: 600;
|
||||
color: $gray-500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
margin-bottom: $sp-4;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: $sp-3;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pagerBtn {
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-sm;
|
||||
.tableWrap {
|
||||
background: $white;
|
||||
color: $gray-700;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-caption;
|
||||
cursor: pointer;
|
||||
transition: .16s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $gold-400;
|
||||
color: $gold-600;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: .4;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.pagerBtnActive {
|
||||
background: $navy-800;
|
||||
border-color: $navy-800;
|
||||
color: $white;
|
||||
|
||||
&:hover {
|
||||
border-color: $navy-800 !important;
|
||||
color: $white !important;
|
||||
}
|
||||
}
|
||||
|
||||
.pagerEllipsis {
|
||||
color: $gray-400;
|
||||
font-size: $fs-caption;
|
||||
padding: 0 2px;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-md;
|
||||
box-shadow: $sh-1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: $fs-caption;
|
||||
font-size: $fs-sm;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: $gray-500;
|
||||
font-size: $fs-caption;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .03em;
|
||||
padding: 6px 8px;
|
||||
padding: 12px 16px;
|
||||
background: $gray-50;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
color: $gray-500;
|
||||
font-size: $fs-label;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
// Высокие строки + перенос по словам: панель узкая, а длинные названия и
|
||||
// e-mail не должны обрезаться — пусть занимают несколько строк.
|
||||
height: 66px;
|
||||
padding: $sp-3 8px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid $gray-100;
|
||||
color: $gray-900;
|
||||
vertical-align: middle;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Кликабельна вся строка (см. RequestsTableView — onClick на <tr>).
|
||||
.row {
|
||||
cursor: pointer;
|
||||
transition: background .16s;
|
||||
|
||||
&:hover {
|
||||
background: $gold-50;
|
||||
}
|
||||
}
|
||||
|
||||
.cellType {
|
||||
width: 72px;
|
||||
color: $gold-600;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cellTitle {
|
||||
// Ширину не задаём — делит оставшееся место с cellAuthor (table-layout: fixed).
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.titleLink {
|
||||
display: block;
|
||||
color: $gray-900;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: $gold-600;
|
||||
text-decoration: underline;
|
||||
&:focus-visible {
|
||||
outline: 2px solid $gold-500;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.cellAuthor {
|
||||
.colId {
|
||||
width: 70px;
|
||||
color: $gray-400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.colType {
|
||||
width: 200px;
|
||||
color: $navy-700;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.colTitle {
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.colDate {
|
||||
width: 110px;
|
||||
color: $gray-500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.colAuthor {
|
||||
width: 280px;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.cellDate {
|
||||
width: 64px;
|
||||
color: $gray-400;
|
||||
}
|
||||
|
||||
.cellStatus {
|
||||
width: 96px;
|
||||
.colStatus {
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.status {
|
||||
// inline-block, а не flex-элемент: бейдж живёт внутри ячейки таблицы и
|
||||
// переносится по словам (напр. «Возвращена на доработку» в узкой колонке).
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
padding: 3px 10px;
|
||||
border-radius: $r-md;
|
||||
font-size: $fs-caption;
|
||||
font-weight: 500;
|
||||
@@ -271,3 +193,52 @@
|
||||
background: $gray-100;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-1;
|
||||
margin-top: $sp-4;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pagerBtn {
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-sm;
|
||||
background: $white;
|
||||
color: $gray-700;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-sm;
|
||||
cursor: pointer;
|
||||
transition: .16s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $gold-400;
|
||||
color: $gold-600;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: .4;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.pagerBtnActive {
|
||||
background: $navy-800;
|
||||
border-color: $navy-800;
|
||||
color: $white;
|
||||
|
||||
&:hover {
|
||||
border-color: $navy-800 !important;
|
||||
color: $white !important;
|
||||
}
|
||||
}
|
||||
|
||||
.pagerEllipsis {
|
||||
color: $gray-400;
|
||||
font-size: $fs-sm;
|
||||
padding: 0 4px;
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import * as React from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { getLinkedTypes, getOwnRequests, getPendingApproval } from './myRequests.service';
|
||||
import { IMyRequest } from './models/IMyRequest';
|
||||
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
|
||||
import { onRequestsChanged } from '../../shared/requestEvents';
|
||||
import styles from './RequestsTableView.module.scss';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export interface IRequestsTableViewProps {
|
||||
context: WebPartContext;
|
||||
/** 'approvals' — ждут решения текущего пользователя; 'own' — созданные им. */
|
||||
kind: 'approvals' | 'own';
|
||||
/** Клик по строке — открыть заявку (в выезжающей панели). */
|
||||
onOpen: (request: IMyRequest) => void;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`;
|
||||
const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`;
|
||||
return `${dd}.${mm}.${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
function statusClass(status: string): string {
|
||||
switch (status) {
|
||||
case REQUEST_STATUS.approved:
|
||||
return styles.statusApproved;
|
||||
case REQUEST_STATUS.rejected:
|
||||
return styles.statusRejected;
|
||||
case REQUEST_STATUS.returned:
|
||||
return styles.statusReturned;
|
||||
case REQUEST_STATUS.onApproval:
|
||||
return styles.statusOnApproval;
|
||||
case REQUEST_STATUS.draft:
|
||||
return styles.statusDraft;
|
||||
default:
|
||||
return styles.statusDefault;
|
||||
}
|
||||
}
|
||||
|
||||
// Постраничная навигация: номера страниц + вперёд/назад. При большом
|
||||
// количестве страниц показывает только соседние с текущей + первую/последнюю.
|
||||
const Pager: React.FC<{ page: number; totalPages: number; onChange: (page: number) => void }> = ({
|
||||
page,
|
||||
totalPages,
|
||||
onChange,
|
||||
}) => {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const numbers: number[] = [];
|
||||
const from = Math.max(1, page - 2);
|
||||
const to = Math.min(totalPages, page + 2);
|
||||
for (let i = from; i <= to; i++) numbers.push(i);
|
||||
|
||||
return (
|
||||
<div className={styles.pager}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pagerBtn}
|
||||
disabled={page <= 1}
|
||||
onClick={() => onChange(page - 1)}
|
||||
aria-label="Предыдущая страница"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
{from > 1 && (
|
||||
<>
|
||||
<button type="button" className={styles.pagerBtn} onClick={() => onChange(1)}>
|
||||
1
|
||||
</button>
|
||||
{from > 2 && <span className={styles.pagerEllipsis}>…</span>}
|
||||
</>
|
||||
)}
|
||||
{numbers.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`${styles.pagerBtn} ${n === page ? styles.pagerBtnActive : ''}`}
|
||||
onClick={() => onChange(n)}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
{to < totalPages && (
|
||||
<>
|
||||
{to < totalPages - 1 && <span className={styles.pagerEllipsis}>…</span>}
|
||||
<button type="button" className={styles.pagerBtn} onClick={() => onChange(totalPages)}>
|
||||
{totalPages}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pagerBtn}
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onChange(page + 1)}
|
||||
aria-label="Следующая страница"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Таблица заявок. Клик в ЛЮБОМ месте строки открывает заявку (кликабельна вся
|
||||
// строка, а не только название), поэтому строка — <tr> с onClick + tabIndex
|
||||
// (доступ с клавиатуры: Enter/Space).
|
||||
//
|
||||
// «approvals»: автор — не текущий пользователь, поэтому нужна колонка «Автор»;
|
||||
// статус не показываем — здесь все заявки по определению на согласовании.
|
||||
// «own»: автор всегда текущий пользователь, зато важен статус.
|
||||
const Table: React.FC<{
|
||||
rows: IMyRequest[];
|
||||
kind: 'approvals' | 'own';
|
||||
onOpen: (request: IMyRequest) => void;
|
||||
}> = ({ rows, kind, onOpen }) => (
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.colId}>ID</th>
|
||||
<th className={styles.colType}>Тип</th>
|
||||
<th className={styles.colTitle}>Название</th>
|
||||
<th className={styles.colDate}>Дата</th>
|
||||
{kind === 'approvals' && <th className={styles.colAuthor}>Автор</th>}
|
||||
{kind === 'own' && <th className={styles.colStatus}>Статус</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr
|
||||
className={styles.row}
|
||||
key={`${r.requestTypeId}-${r.id}`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`Открыть заявку «${r.title || r.id}»`}
|
||||
onClick={() => onOpen(r)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onOpen(r);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td className={styles.colId}>{r.id}</td>
|
||||
<td className={styles.colType}>{r.requestTypeName}</td>
|
||||
<td className={styles.colTitle}>{r.title || `#${r.id}`}</td>
|
||||
<td className={styles.colDate}>{formatDate(r.created)}</td>
|
||||
{kind === 'approvals' && (
|
||||
<td className={styles.colAuthor}>
|
||||
{[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'}
|
||||
</td>
|
||||
)}
|
||||
{kind === 'own' && (
|
||||
<td className={styles.colStatus}>
|
||||
<span className={`${styles.status} ${statusClass(r.status)}`}>{r.status || '—'}</span>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Раздел с таблицей заявок: поиск по названию + постраничный вывод.
|
||||
//
|
||||
// 'approvals' грузится целиком (алгоритм «чья сейчас очередь» требует видеть
|
||||
// весь маршрут заявки), поиск и пагинация — на клиенте.
|
||||
// 'own' идёт постранично через обычный REST (см. myRequests.service), поиск —
|
||||
// серверным OData-фильтром по Title.
|
||||
//
|
||||
// Оба варианта перечитываются по событию rp-requests-changed
|
||||
// (shared/requestEvents) — после создания заявки и после решения согласующего.
|
||||
export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({ context, kind, onOpen }) => {
|
||||
const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
|
||||
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
|
||||
const [totalRows, setTotalRows] = React.useState<number>(0);
|
||||
const [page, setPage] = React.useState<number>(1);
|
||||
const [query, setQuery] = React.useState<string>('');
|
||||
const [error, setError] = React.useState<string>('');
|
||||
|
||||
React.useEffect(() => setPage(1), [query, kind]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
getLinkedTypes(context)
|
||||
.then((t) => {
|
||||
if (!cancelled) setTypes(t);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError((e as Error).message);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [context]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (types === undefined) return;
|
||||
let cancelled = false;
|
||||
|
||||
const load = (): void => {
|
||||
const request =
|
||||
kind === 'own'
|
||||
? getOwnRequests(context, types, query, page).then((r) => ({ rows: r.rows, total: r.totalRows }))
|
||||
: getPendingApproval(context).then((r) => {
|
||||
console.table(r.debug);
|
||||
return { rows: r.rows, total: r.rows.length };
|
||||
});
|
||||
|
||||
request
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setRows(result.rows);
|
||||
setTotalRows(result.total);
|
||||
setError('');
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError((e as Error).message);
|
||||
});
|
||||
};
|
||||
|
||||
load();
|
||||
const unsubscribe = onRequestsChanged(load);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [context, types, kind, query, page]);
|
||||
|
||||
// 'approvals' приходит целиком — фильтруем и нарезаем страницы на клиенте.
|
||||
const q = query.trim().toLowerCase();
|
||||
let visible = rows || [];
|
||||
let total = totalRows;
|
||||
if (kind === 'approvals' && rows) {
|
||||
const filtered = q ? rows.filter((r) => r.title.toLowerCase().indexOf(q) !== -1) : rows;
|
||||
total = filtered.length;
|
||||
visible = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
}
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
const heading = kind === 'approvals' ? 'Ждут вашего решения' : 'Мои заявки';
|
||||
|
||||
return (
|
||||
<section className={styles.view}>
|
||||
<div className={styles.head}>
|
||||
<h2 className={styles.heading}>
|
||||
{heading}
|
||||
{rows && <span className={styles.count}>{total}</span>}
|
||||
</h2>
|
||||
<input
|
||||
type="search"
|
||||
className={styles.search}
|
||||
placeholder="Поиск по названию…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.error}>Не удалось загрузить заявки: {error}</div>}
|
||||
{!error && rows === undefined && <div className={styles.empty}>Загрузка…</div>}
|
||||
{!error && rows && total === 0 && (
|
||||
<div className={styles.empty}>
|
||||
{q
|
||||
? `По запросу «${query}» ничего не найдено.`
|
||||
: kind === 'approvals'
|
||||
? 'Нет заявок, ждущих вашего решения.'
|
||||
: 'Вы ещё не создавали заявок.'}
|
||||
</div>
|
||||
)}
|
||||
{!error && rows && total > 0 && (
|
||||
<>
|
||||
<Table rows={visible} kind={kind} onOpen={onOpen} />
|
||||
<Pager page={page} totalPages={totalPages} onChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,12 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
// Центрированная колонка контента. 1080px — ширина контента из дизайн-системы
|
||||
// (как .sec в референсе): форма и плитки читаются аккуратно, не растягиваясь.
|
||||
// Колонка контента. 1080px — ширина контента из дизайн-системы (как .sec в
|
||||
// референсе): форма и плитки читаются аккуратно, не растягиваясь. Прижата
|
||||
// влево (без margin: auto), иначе на широком экране между боковым меню и
|
||||
// формой появлялась пустая полоса, а вкладки категорий сверху шли шире формы.
|
||||
.content {
|
||||
width: 100%;
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
// ── Заглушка формы (до появления реальных форм) ──
|
||||
|
||||
@@ -2,24 +2,32 @@ import * as React from 'react';
|
||||
|
||||
// Маршрутизация портала по параметрам URL.
|
||||
//
|
||||
// Обычный режим (нет параметров) — подача заявок (features/submit).
|
||||
// Режим согласующего — переход по ссылке из потока Power Automate:
|
||||
// Раздел бокового меню — rpnav: 'new' (подать заявку, по умолчанию),
|
||||
// 'approvals' (ждут моего решения), 'own' (мои заявки).
|
||||
// Открытая заявка — переход по ссылке из потока Power Automate или клик по
|
||||
// строке таблицы:
|
||||
// …/СтраницаПортала.aspx?rpmode=approve&rptype=<форма>&rpid=<RowID>
|
||||
// где rptype = ключ формы в реестре (forms/registry), rpid = RowID заявки в списке данных.
|
||||
// где rptype = ключ формы в реестре (forms/registry), rpid = RowID заявки в
|
||||
// списке данных. Заявка показывается в выезжающей панели ПОВЕРХ текущего
|
||||
// раздела, поэтому rpnav сохраняется вместе с rpmode.
|
||||
//
|
||||
// Префикс rp (request-portal): имена id/mode/type зарезервированы SharePoint
|
||||
// (напр. ?id= он трактует как навигацию к элементу → «Элемент не существует»),
|
||||
// поэтому используем собственные, неконфликтные имена параметров.
|
||||
//
|
||||
// Навигация внутри портала (напр. клик по заявке в «Мои заявки») идёт через
|
||||
// navigate() — это history.pushState без перезагрузки страницы. useRoute()
|
||||
// подписан и на popstate (кнопка «назад»), и на её собственное событие,
|
||||
// поэтому реагирует на оба способа смены URL.
|
||||
// Навигация внутри портала идёт через navigate() — это history.pushState без
|
||||
// перезагрузки страницы. useRoute() подписан и на popstate (кнопка «назад»),
|
||||
// и на её собственное событие, поэтому реагирует на оба способа смены URL.
|
||||
|
||||
export type PortalMode = 'submit' | 'approve' | 'create';
|
||||
|
||||
/** Раздел бокового меню. */
|
||||
export type NavSection = 'new' | 'approvals' | 'own';
|
||||
|
||||
export interface IRoute {
|
||||
mode: PortalMode;
|
||||
/** Раздел бокового меню; сохраняется и когда поверх открыта заявка. */
|
||||
nav: NavSection;
|
||||
/** Ключ формы (тип заявки) — для 'approve' и 'create'. */
|
||||
formKey?: string;
|
||||
/** Id заявки в списке данных — для 'approve'. */
|
||||
@@ -30,62 +38,45 @@ export interface IRoute {
|
||||
|
||||
const ROUTE_CHANGE_EVENT = 'rp-route-change';
|
||||
|
||||
function parseNav(raw: string | undefined): NavSection {
|
||||
if (raw === 'approvals' || raw === 'own') return raw;
|
||||
return 'new';
|
||||
}
|
||||
|
||||
/** Разобрать строку запроса (по умолчанию — из адреса страницы) в маршрут. */
|
||||
export function parseRoute(search: string = window.location.search): IRoute {
|
||||
const params = new URLSearchParams(search);
|
||||
const mode = params.get('rpmode');
|
||||
const nav = parseNav(params.get('rpnav') ?? undefined);
|
||||
const formKey = params.get('rptype') ?? undefined;
|
||||
const idRaw = params.get('rpid');
|
||||
const itemId = idRaw ? Number(idRaw) : undefined;
|
||||
const sourceRaw = params.get('rpsource');
|
||||
const sourceId = sourceRaw ? Number(sourceRaw) : undefined;
|
||||
|
||||
// Режим согласующего — при полном наборе параметров.
|
||||
// Открытая заявка (панель поверх раздела) — при полном наборе параметров.
|
||||
if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) {
|
||||
return { mode: 'approve', formKey, itemId };
|
||||
return { mode: 'approve', nav, formKey, itemId };
|
||||
}
|
||||
// Создание производной заявки с предзаполнением из исходной.
|
||||
if (formKey && sourceId && !isNaN(sourceId)) {
|
||||
return { mode: 'create', formKey, sourceId };
|
||||
return { mode: 'create', nav: 'new', formKey, sourceId };
|
||||
}
|
||||
return { mode: 'submit' };
|
||||
return { mode: 'submit', nav };
|
||||
}
|
||||
|
||||
/**
|
||||
* Перейти на другой маршрут без перезагрузки страницы (SPA): обновляет
|
||||
* query-параметры адресной строки через history.pushState и оповещает
|
||||
* useRoute() своим событием (popstate на pushState сам не срабатывает).
|
||||
*/
|
||||
export function navigate(route: IRoute): void {
|
||||
// Собрать query-строку маршрута, сохранив посторонние параметры страницы.
|
||||
function buildQuery(route: IRoute): string {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete('rpmode');
|
||||
params.delete('rpnav');
|
||||
params.delete('rptype');
|
||||
params.delete('rpid');
|
||||
params.delete('rpsource');
|
||||
|
||||
if (route.mode === 'approve' && route.formKey && route.itemId) {
|
||||
params.set('rpmode', 'approve');
|
||||
params.set('rptype', route.formKey);
|
||||
params.set('rpid', String(route.itemId));
|
||||
} else if (route.mode === 'create' && route.formKey && route.sourceId) {
|
||||
params.set('rptype', route.formKey);
|
||||
params.set('rpsource', String(route.sourceId));
|
||||
if (route.nav !== 'new') {
|
||||
params.set('rpnav', route.nav);
|
||||
}
|
||||
|
||||
const query = params.toString();
|
||||
const url = `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`;
|
||||
window.history.pushState({}, '', url);
|
||||
window.dispatchEvent(new Event(ROUTE_CHANGE_EVENT));
|
||||
}
|
||||
|
||||
/** Ссылка на маршрут (для href — открытие в новой вкладке, копирование и т.п.). */
|
||||
export function routeUrl(route: IRoute): string {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete('rpmode');
|
||||
params.delete('rptype');
|
||||
params.delete('rpid');
|
||||
params.delete('rpsource');
|
||||
|
||||
if (route.mode === 'approve' && route.formKey && route.itemId) {
|
||||
params.set('rpmode', 'approve');
|
||||
params.set('rptype', route.formKey);
|
||||
@@ -99,6 +90,21 @@ export function routeUrl(route: IRoute): string {
|
||||
return `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Перейти на другой маршрут без перезагрузки страницы (SPA): обновляет
|
||||
* query-параметры адресной строки через history.pushState и оповещает
|
||||
* useRoute() своим событием (popstate на pushState сам не срабатывает).
|
||||
*/
|
||||
export function navigate(route: IRoute): void {
|
||||
window.history.pushState({}, '', buildQuery(route));
|
||||
window.dispatchEvent(new Event(ROUTE_CHANGE_EVENT));
|
||||
}
|
||||
|
||||
/** Ссылка на маршрут (для href — открытие в новой вкладке, копирование и т.п.). */
|
||||
export function routeUrl(route: IRoute): string {
|
||||
return buildQuery(route);
|
||||
}
|
||||
|
||||
/** Текущий маршрут, реактивно обновляется по navigate() и кнопке «назад». */
|
||||
export function useRoute(): IRoute {
|
||||
const [route, setRoute] = React.useState<IRoute>(() => parseRoute());
|
||||
|
||||
Reference in New Issue
Block a user