Панель со списками заявок #1
@@ -8,3 +8,6 @@ jest-output/
|
|||||||
*.sppkg
|
*.sppkg
|
||||||
.heft/
|
.heft/
|
||||||
sharepoint/solution/
|
sharepoint/solution/
|
||||||
|
|
||||||
|
# JetBrains IDEA
|
||||||
|
.idea/
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
margin-top: $sp-6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile {
|
.tile {
|
||||||
@@ -99,7 +98,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
margin-top: $sp-6;
|
|
||||||
color: $gray-500;
|
color: $gray-500;
|
||||||
font-size: $fs-sm;
|
font-size: $fs-sm;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,13 +32,13 @@
|
|||||||
font-family: $font-sans;
|
font-family: $font-sans;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Двухколоночная раскладка: формы (main) + постоянная панель (side).
|
// Раскладка: боковое меню (side) + основная область (main).
|
||||||
.layout {
|
.layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: $sp-6;
|
gap: $sp-6;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1400px;
|
max-width: 1600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,21 +47,27 @@
|
|||||||
min-width: 0; // чтобы контент не распирал колонку
|
min-width: 0; // чтобы контент не распирал колонку
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ширину задаёт само меню (SideNav сворачивается) — колонка просто под него.
|
||||||
.side {
|
.side {
|
||||||
flex: 0 0 380px;
|
flex: 0 0 auto;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: $sp-4;
|
top: $sp-4;
|
||||||
align-self: stretch;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// На узких экранах панель уходит под формы.
|
// Та же ширина, что у контента подачи (SubmitFeature .content) — чтобы вкладки
|
||||||
|
// не уезжали шире формы под ними.
|
||||||
|
.tabsWrap {
|
||||||
|
max-width: 1080px;
|
||||||
|
margin-bottom: $sp-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// На узких экранах меню уходит наверх, в одну строку.
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.layout {
|
.layout {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side {
|
.side {
|
||||||
flex-basis: auto;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,41 +2,123 @@ import * as React from 'react';
|
|||||||
import styles from './RequestPortal.module.scss';
|
import styles from './RequestPortal.module.scss';
|
||||||
import type { IRequestPortalProps } from './IRequestPortalProps';
|
import type { IRequestPortalProps } from './IRequestPortalProps';
|
||||||
import { IconSprite } from '../shared/ui/Icon/IconSprite';
|
import { IconSprite } from '../shared/ui/Icon/IconSprite';
|
||||||
import { parseRoute } 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 SubmitFeature from '../features/submit/SubmitFeature';
|
||||||
import ApproveFeature from '../features/approve/ApproveFeature';
|
import ApproveFeature from '../features/approve/ApproveFeature';
|
||||||
|
import { RequestsTableView } from '../features/myRequests/RequestsTableView';
|
||||||
|
import { getPendingApproval } from '../features/myRequests/myRequests.service';
|
||||||
|
|
||||||
// Оболочка портала. Логики фич тут нет — файл общий, меняется редко.
|
// Оболочка портала: тёмное боковое меню (разделы) + основная область.
|
||||||
// Роутинг по URL: mode=approve&type=&id= → фича согласования, иначе — подача.
|
// Логики фич тут нет — файл общий, меняется редко.
|
||||||
//
|
//
|
||||||
// Панель «Мои заявки» (features/myRequests) готова как компонент, но НЕ смонтирована —
|
// Разделы (rpnav): 'new' — подача заявки (вкладки категорий + плитки + форма),
|
||||||
// её подключает второй разработчик, когда наполнит. Для этого:
|
// 'approvals'/'own' — таблицы заявок. Клик по строке таблицы открывает заявку
|
||||||
// 1) import MyRequestsPanel from '../features/myRequests/MyRequestsPanel';
|
// в выезжающей панели ПОВЕРХ раздела (rpmode=approve&rptype=&rpid=), поэтому
|
||||||
// 2) обернуть контент подачи в <div className={styles.layout}> с двумя колонками:
|
// раздел под ней сохраняется; та же ссылка приходит из потока Power Automate.
|
||||||
// <main className={styles.main}><SubmitFeature .../></main>
|
|
||||||
// <div className={styles.side}><MyRequestsPanel context={context} /></div>
|
|
||||||
// (классы layout/main/side уже есть в RequestPortal.module.scss)
|
|
||||||
const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context }) => {
|
const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context }) => {
|
||||||
const rootClasses = [styles.requestPortal, hasTeamsContext ? styles.teams : '']
|
const rootClasses = [styles.requestPortal, hasTeamsContext ? styles.teams : '']
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
|
|
||||||
const route = parseRoute();
|
const route = useRoute();
|
||||||
|
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) {
|
||||||
|
const found = REQUEST_TYPES.filter((r) => r.id === route.formKey)[0];
|
||||||
|
if (found) return found.categoryId;
|
||||||
|
}
|
||||||
|
return DEFAULT_CATEGORY_ID;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Счётчик на пункте «На согласовании». Обновляется по тому же событию, что и
|
||||||
|
// сами таблицы (создание заявки, решение согласующего).
|
||||||
|
React.useEffect(() => {
|
||||||
|
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 (
|
return (
|
||||||
<section className={rootClasses}>
|
<section className={rootClasses}>
|
||||||
<IconSprite />
|
<IconSprite />
|
||||||
{route.mode === 'approve' && route.formKey && route.itemId ? (
|
<div className={styles.layout}>
|
||||||
<ApproveFeature context={context} formKey={route.formKey} itemId={route.itemId} />
|
<div className={styles.side}>
|
||||||
) : (
|
<SideNav
|
||||||
<SubmitFeature
|
activeId={route.nav}
|
||||||
context={context}
|
onChange={goto}
|
||||||
initialForm={
|
approvalsCount={approvalsCount}
|
||||||
route.mode === 'create' && route.formKey && route.sourceId
|
collapsed={navCollapsed}
|
||||||
? { formKey: route.formKey, sourceId: route.sourceId }
|
onToggleCollapsed={() => setNavCollapsed((v) => !v)}
|
||||||
: undefined
|
/>
|
||||||
}
|
</div>
|
||||||
/>
|
|
||||||
|
<main className={styles.main}>
|
||||||
|
{route.nav === 'new' ? (
|
||||||
|
<>
|
||||||
|
<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
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<RequestsTableView
|
||||||
|
context={context}
|
||||||
|
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>
|
</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,33 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
padding: $sp-4 $sp-5;
|
|
||||||
border-bottom: 1px solid $gray-200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-family: $font-serif;
|
|
||||||
font-size: 18px;
|
|
||||||
color: $navy-900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: $sp-5;
|
|
||||||
color: $gray-500;
|
|
||||||
font-size: $fs-sm;
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
|
||||||
import styles from './MyRequestsPanel.module.scss';
|
|
||||||
|
|
||||||
export interface IMyRequestsPanelProps {
|
|
||||||
context: WebPartContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Постоянная боковая панель «Мои заявки»: пользователь видит, что он отправлял
|
|
||||||
// и согласовывал. Всегда открыта (без кнопки/оверлея).
|
|
||||||
// Владелец: второй разработчик. Вся логика/данные — внутри этой папки.
|
|
||||||
// Общий UI-кит (shared/ui), pnpjs и config можно импортировать, но НЕ менять.
|
|
||||||
const MyRequestsPanel: React.FC<IMyRequestsPanelProps> = (/* { context } */) => {
|
|
||||||
return (
|
|
||||||
<aside className={styles.panel} aria-label="Мои заявки">
|
|
||||||
<div className={styles.header}>
|
|
||||||
<span className={styles.title}>Мои заявки</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.body}>
|
|
||||||
Раздел в разработке: здесь будут заявки пользователя, статусы и согласования.
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MyRequestsPanel;
|
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.view {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $sp-4;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: $sp-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-3;
|
||||||
|
margin: 0;
|
||||||
|
font-family: $font-serif;
|
||||||
|
font-size: $fs-page-title;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 280px;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 9px 13px;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $white;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-900;
|
||||||
|
transition: .18s;
|
||||||
|
|
||||||
|
@include input-focus-gold;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrap {
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-md;
|
||||||
|
box-shadow: $sh-1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
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 {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid $gray-100;
|
||||||
|
color: $gray-900;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кликабельна вся строка (см. RequestsTableView — onClick на <tr>).
|
||||||
|
.row {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .16s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gold-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid $gold-500;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colStatus {
|
||||||
|
width: 190px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: $r-md;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusDraft {
|
||||||
|
background: $gray-100;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusOnApproval {
|
||||||
|
background: $gold-100;
|
||||||
|
color: $gold-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusApproved {
|
||||||
|
background: $green-50;
|
||||||
|
color: $green-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRejected {
|
||||||
|
background: $red-50;
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusReturned {
|
||||||
|
background: $blue-50;
|
||||||
|
color: $blue-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusDefault {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Строка заявки пользователя для боковой панели «Мои заявки».
|
||||||
|
// Собирается из списка данных SharePoint конкретного типа заявки (см. myRequests.service.ts).
|
||||||
|
export interface IMyRequest {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
authorEmail: string;
|
||||||
|
authorName: string;
|
||||||
|
/** Дата создания (ISO-строка, колонка Created). */
|
||||||
|
created: string;
|
||||||
|
requestTypeId: number;
|
||||||
|
requestTypeName: string;
|
||||||
|
section: string;
|
||||||
|
/** Ключ формы (rptype в URL режима согласования) — поле pageUrl из approval-route API. */
|
||||||
|
formKey: string;
|
||||||
|
/** 'own' — заявка, созданная текущим пользователем; 'approval' — ждёт его решения. */
|
||||||
|
kind: 'own' | 'approval';
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Диагностика по одному типу заявки — для отладочного блока в панели «Мои заявки».
|
||||||
|
export interface IMyRequestsTypeDebug {
|
||||||
|
requestTypeId: number;
|
||||||
|
requestTypeName: string;
|
||||||
|
listId?: string;
|
||||||
|
siteUrl?: string;
|
||||||
|
/** Тип пропущен: у него нет listId/siteUrl в approval-route API. */
|
||||||
|
skipped: boolean;
|
||||||
|
/** Сколько строк текущего пользователя найдено (undefined, если не дошли до запроса). */
|
||||||
|
rowCount?: number;
|
||||||
|
/** Текст ошибки, если запрос к списку упал. */
|
||||||
|
error?: string;
|
||||||
|
|
||||||
|
/** Заголовок списка согласования, вычисленный из имени списка данных. */
|
||||||
|
approvalListTitle?: string;
|
||||||
|
/** Список данных не похож на «... — данные» — список согласования не искали. */
|
||||||
|
approvalSkippedReason?: string;
|
||||||
|
/** Сколько строк маршрута (фаза «Согласование») прочитано из списка согласования. */
|
||||||
|
approvalRowCount?: number;
|
||||||
|
/** Сколько заявок сейчас ждут решения текущего пользователя. */
|
||||||
|
approvalPendingTickets?: number;
|
||||||
|
/** Текст ошибки, если запрос к списку согласования упал. */
|
||||||
|
approvalError?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import { spfi, SPFx } from '@pnp/sp';
|
||||||
|
import '@pnp/sp/webs';
|
||||||
|
import '@pnp/sp/lists';
|
||||||
|
import '@pnp/sp/items';
|
||||||
|
import '@pnp/sp/site-users/web';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { getRequestTypes, IRequestType, REQUEST_STATUS } from '../../shared/approval';
|
||||||
|
import { IMyRequest } from './models/IMyRequest';
|
||||||
|
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
|
||||||
|
|
||||||
|
// Список данных «... — данные» → список согласования «... — согласование»
|
||||||
|
// (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json).
|
||||||
|
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
|
||||||
|
|
||||||
|
interface IApprovalStep {
|
||||||
|
status: string;
|
||||||
|
order?: number;
|
||||||
|
approverEmail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentUserEmail(context: WebPartContext): string {
|
||||||
|
return (context.pageContext.user.email || '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Типы заявок, у которых approval-route API уже отдал listId/siteUrl (готовы к чтению).
|
||||||
|
export async function getLinkedTypes(context: WebPartContext): Promise<IRequestType[]> {
|
||||||
|
const types = await getRequestTypes(context);
|
||||||
|
return types.filter((t) => !!t.listId && !!t.siteUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// «Ждёт вашего решения» — требует видеть ВЕСЬ маршрут заявки, чтобы понять,
|
||||||
|
// чья сейчас очередь, поэтому идёт обычным REST-запросом (не через Search),
|
||||||
|
// целиком за один раз. Пагинация по этому результату — на стороне панели
|
||||||
|
// (клиентская нарезка по страницам), т.к. сам список обычно небольшой
|
||||||
|
// (сколько заявок сейчас реально ждут решения ОДНОГО согласующего).
|
||||||
|
export async function getPendingApproval(
|
||||||
|
context: WebPartContext
|
||||||
|
): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug[] }> {
|
||||||
|
const types = await getLinkedTypes(context);
|
||||||
|
const tokenEmail = currentUserEmail(context);
|
||||||
|
|
||||||
|
const perType = await Promise.all(
|
||||||
|
types.map((t) => getApprovalPendingForType(context, t, tokenEmail))
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: perType.reduce((all, r) => all.concat(r.rows), [] as IMyRequest[]),
|
||||||
|
debug: perType.map((r) => r.debug),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getApprovalPendingForType(
|
||||||
|
context: WebPartContext,
|
||||||
|
type: IRequestType,
|
||||||
|
tokenEmail: string
|
||||||
|
): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug }> {
|
||||||
|
const debug: IMyRequestsTypeDebug = {
|
||||||
|
requestTypeId: type.id,
|
||||||
|
requestTypeName: type.name,
|
||||||
|
listId: type.listId,
|
||||||
|
siteUrl: type.siteUrl,
|
||||||
|
skipped: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sp = spfi(type.siteUrl as string).using(SPFx(context));
|
||||||
|
const list = sp.web.getList(type.listId as string);
|
||||||
|
|
||||||
|
const dataListInfo = await list.select('Title')();
|
||||||
|
const dataTitle = (dataListInfo.Title as string) || '';
|
||||||
|
if (!DATA_LIST_SUFFIX.test(dataTitle)) {
|
||||||
|
debug.approvalSkippedReason = `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`;
|
||||||
|
return { rows: [], debug };
|
||||||
|
}
|
||||||
|
const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
|
||||||
|
debug.approvalListTitle = approvalListTitle;
|
||||||
|
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
|
||||||
|
|
||||||
|
// Phase не смотрим вообще — работаем только по Status.
|
||||||
|
const stepRows = (await approvalList.items
|
||||||
|
.select('Id', 'StepOrder', 'Status', 'TicketId', 'Approver/EMail')
|
||||||
|
.expand('Approver')
|
||||||
|
.top(2000)()) as Array<Record<string, unknown>>;
|
||||||
|
console.log(
|
||||||
|
`[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», элементов в списке: ${stepRows.length}, ваш email из токена: «${tokenEmail}»`
|
||||||
|
);
|
||||||
|
console.table(
|
||||||
|
stepRows.map((r) => ({
|
||||||
|
Id: r.Id,
|
||||||
|
TicketId: r.TicketId,
|
||||||
|
StepOrder: r.StepOrder,
|
||||||
|
Status: r.Status,
|
||||||
|
'Approver/EMail': (r.Approver as { EMail?: string } | undefined)?.EMail,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
debug.approvalRowCount = stepRows.length;
|
||||||
|
|
||||||
|
const byTicket = new Map<number, IApprovalStep[]>();
|
||||||
|
stepRows.forEach((r) => {
|
||||||
|
const ticketId = r.TicketId as number | undefined;
|
||||||
|
if (!ticketId) return;
|
||||||
|
const approver = r.Approver as { EMail?: string } | undefined;
|
||||||
|
const steps = byTicket.get(ticketId) || [];
|
||||||
|
steps.push({
|
||||||
|
status: (r.Status as string) || '',
|
||||||
|
order: typeof r.StepOrder === 'number' ? (r.StepOrder as number) : undefined,
|
||||||
|
approverEmail: (approver?.EMail || '').toLowerCase(),
|
||||||
|
});
|
||||||
|
byTicket.set(ticketId, steps);
|
||||||
|
});
|
||||||
|
|
||||||
|
const myTicketIds: number[] = [];
|
||||||
|
const byTicketDebug: Array<Record<string, unknown>> = [];
|
||||||
|
byTicket.forEach((steps, ticketId) => {
|
||||||
|
const mine = isMyTurn(steps, tokenEmail);
|
||||||
|
byTicketDebug.push({ TicketId: ticketId, isMyTurn: mine, steps: JSON.stringify(steps) });
|
||||||
|
if (mine) myTicketIds.push(ticketId);
|
||||||
|
});
|
||||||
|
console.table(byTicketDebug);
|
||||||
|
|
||||||
|
debug.approvalPendingTickets = myTicketIds.length;
|
||||||
|
if (myTicketIds.length === 0) return { rows: [], debug };
|
||||||
|
|
||||||
|
const ticketFilter = myTicketIds.map((id) => `Id eq ${id}`).join(' or ');
|
||||||
|
const ticketRows = (await list.items
|
||||||
|
.filter(ticketFilter)
|
||||||
|
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
|
||||||
|
.expand('Author')
|
||||||
|
.top(2000)()) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
const rows = ticketRows.map((r) => {
|
||||||
|
const author = r.Author as { EMail?: string; Title?: string } | undefined;
|
||||||
|
return toMyRequest(r, author, type, 'approval');
|
||||||
|
});
|
||||||
|
return { rows, debug };
|
||||||
|
} catch (e) {
|
||||||
|
debug.approvalError = (e as Error).message;
|
||||||
|
console.error(
|
||||||
|
`Мои заявки: не удалось прочитать согласование для «${type.name}»:`,
|
||||||
|
debug.approvalError
|
||||||
|
);
|
||||||
|
return { rows: [], debug };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сейчас ли очередь пользователя с данным email среди строк маршрута одной заявки.
|
||||||
|
// Маршрут остановлен (где-то есть Отклонено/Возвращена) — не его очередь, заявка
|
||||||
|
// не активна. Иначе «текущие» строки — это ещё не решённые (Status = На согласовании)
|
||||||
|
// без указанного StepOrder (параллельно, все активны сразу) плюс, если есть строки
|
||||||
|
// с указанным StepOrder, ещё не решённая с минимальным StepOrder (последовательно).
|
||||||
|
function isMyTurn(steps: IApprovalStep[], tokenEmail: string): boolean {
|
||||||
|
if (!tokenEmail) return false;
|
||||||
|
const stopped = steps.some(
|
||||||
|
(s) => s.status === REQUEST_STATUS.rejected || s.status === REQUEST_STATUS.returned
|
||||||
|
);
|
||||||
|
if (stopped) return false;
|
||||||
|
|
||||||
|
const pending = steps.filter((s) => s.status === REQUEST_STATUS.onApproval);
|
||||||
|
const noOrder = pending.filter((s) => s.order === undefined);
|
||||||
|
const ordered = pending.filter((s): s is IApprovalStep & { order: number } => s.order !== undefined);
|
||||||
|
|
||||||
|
const current = [...noOrder];
|
||||||
|
if (ordered.length > 0) {
|
||||||
|
const minOrder = Math.min(...ordered.map((s) => s.order));
|
||||||
|
current.push(...ordered.filter((s) => s.order === minOrder));
|
||||||
|
}
|
||||||
|
|
||||||
|
return current.some((s) => s.approverEmail === tokenEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// «Созданы вами» — обычный REST по спискам данных (НЕ Search): данные всегда
|
||||||
|
// актуальны, только что созданная заявка видна сразу (у поискового индекса
|
||||||
|
// была бы задержка переиндексации).
|
||||||
|
//
|
||||||
|
// Пагинация «сшивает» несколько списков в один нумерованный список: сначала по
|
||||||
|
// каждому типу берём только лёгкие поля (Id + Created) всех заявок автора,
|
||||||
|
// сортируем объединённый набор по дате создания и лишь для нужной страницы
|
||||||
|
// дочитываем полные поля. Так на экран не тянется всё содержимое заявок,
|
||||||
|
// но общее число страниц известно точно.
|
||||||
|
export interface IOwnRequestsPage {
|
||||||
|
rows: IMyRequest[];
|
||||||
|
totalRows: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
// Столько «лёгких» строк (Id + Created) максимум читаем из одного списка, чтобы
|
||||||
|
// посчитать общее число страниц. Хватает с большим запасом; если у кого-то
|
||||||
|
// заявок окажется больше — в консоль уйдёт предупреждение.
|
||||||
|
const INDEX_LIMIT = 5000;
|
||||||
|
|
||||||
|
// В OData-строке одинарная кавычка экранируется удвоением.
|
||||||
|
function escapeOData(text: string): string {
|
||||||
|
return text.replace(/'/g, "''");
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IOwnIndexEntry {
|
||||||
|
id: number;
|
||||||
|
created: string;
|
||||||
|
type: IRequestType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOwnRequests(
|
||||||
|
context: WebPartContext,
|
||||||
|
types: IRequestType[],
|
||||||
|
query: string,
|
||||||
|
page: number
|
||||||
|
): Promise<IOwnRequestsPage> {
|
||||||
|
if (types.length === 0) return { rows: [], totalRows: 0 };
|
||||||
|
|
||||||
|
const q = query.trim();
|
||||||
|
|
||||||
|
// 1) Лёгкий индекс по каждому типу: только Id + Created заявок автора.
|
||||||
|
const perType = await Promise.all(
|
||||||
|
types.map(async (type): Promise<IOwnIndexEntry[]> => {
|
||||||
|
try {
|
||||||
|
const sp = spfi(type.siteUrl as string).using(SPFx(context));
|
||||||
|
const me = await sp.web.currentUser.select('Id')();
|
||||||
|
const filters = [`AuthorId eq ${me.Id}`];
|
||||||
|
if (q) filters.push(`substringof('${escapeOData(q)}',Title)`);
|
||||||
|
|
||||||
|
const rows = (await sp.web
|
||||||
|
.getList(type.listId as string)
|
||||||
|
.items.filter(filters.join(' and '))
|
||||||
|
.select('Id', 'Created')
|
||||||
|
.orderBy('Created', false)
|
||||||
|
.top(INDEX_LIMIT)()) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
if (rows.length === INDEX_LIMIT) {
|
||||||
|
console.warn(
|
||||||
|
`[Текущие заявки] «${type.name}»: индекс своих заявок обрезан на ${INDEX_LIMIT} — часть страниц не видна`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return rows.map((r) => ({ id: r.Id as number, created: (r.Created as string) || '', type }));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Текущие заявки] «${type.name}»: не удалось прочитать свои заявки:`, (e as Error).message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2) Общий список всех типов, свежие сверху.
|
||||||
|
const index = perType
|
||||||
|
.reduce((all, rows) => all.concat(rows), [] as IOwnIndexEntry[])
|
||||||
|
.sort((a, b) => b.created.localeCompare(a.created));
|
||||||
|
|
||||||
|
const pageEntries = index.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||||
|
if (pageEntries.length === 0) {
|
||||||
|
return { rows: [], totalRows: index.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Полные поля — только для заявок текущей страницы, по своему списку на тип.
|
||||||
|
const idsByType = new Map<IRequestType, number[]>();
|
||||||
|
pageEntries.forEach((e) => {
|
||||||
|
const ids = idsByType.get(e.type) || [];
|
||||||
|
ids.push(e.id);
|
||||||
|
idsByType.set(e.type, ids);
|
||||||
|
});
|
||||||
|
|
||||||
|
const byKey = new Map<string, IMyRequest>();
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(idsByType.entries()).map(async ([type, ids]) => {
|
||||||
|
const sp = spfi(type.siteUrl as string).using(SPFx(context));
|
||||||
|
const rows = (await sp.web
|
||||||
|
.getList(type.listId as string)
|
||||||
|
.items.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
|
||||||
|
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
|
||||||
|
.expand('Author')
|
||||||
|
.top(PAGE_SIZE)()) as Array<Record<string, unknown>>;
|
||||||
|
rows.forEach((r) => {
|
||||||
|
const author = r.Author as { EMail?: string; Title?: string } | undefined;
|
||||||
|
byKey.set(`${type.id}-${r.Id as number}`, toMyRequest(r, author, type, 'own'));
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Порядок страницы задаёт индекс (по дате), а не порядок ответов REST.
|
||||||
|
const rows = pageEntries
|
||||||
|
.map((e) => byKey.get(`${e.type.id}-${e.id}`))
|
||||||
|
.filter((r): r is IMyRequest => !!r);
|
||||||
|
|
||||||
|
return { rows, totalRows: index.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMyRequest(
|
||||||
|
r: Record<string, unknown>,
|
||||||
|
author: { EMail?: string; Title?: string } | undefined,
|
||||||
|
type: IRequestType,
|
||||||
|
kind: 'own' | 'approval'
|
||||||
|
): IMyRequest {
|
||||||
|
return {
|
||||||
|
id: r.Id as number,
|
||||||
|
title: (r.Title as string) || '',
|
||||||
|
status: (r.Status as string) || '',
|
||||||
|
authorEmail: author?.EMail || '',
|
||||||
|
authorName: author?.Title || '',
|
||||||
|
created: (r.Created as string) || '',
|
||||||
|
requestTypeId: type.id,
|
||||||
|
requestTypeName: type.name,
|
||||||
|
section: type.section,
|
||||||
|
formKey: type.pageUrl,
|
||||||
|
kind,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
@import '../../../../styles/design-tokens';
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
// Центрированная колонка контента. 1080px — ширина контента из дизайн-системы
|
// Колонка контента. 1080px — ширина контента из дизайн-системы (как .sec в
|
||||||
// (как .sec в референсе): форма и плитки читаются аккуратно, не растягиваясь.
|
// референсе): форма и плитки читаются аккуратно, не растягиваясь. Прижата
|
||||||
|
// влево (без margin: auto), иначе на широком экране между боковым меню и
|
||||||
|
// формой появлялась пустая полоса, а вкладки категорий сверху шли шире формы.
|
||||||
.content {
|
.content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1080px;
|
max-width: 1080px;
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Заглушка формы (до появления реальных форм) ──
|
// ── Заглушка формы (до появления реальных форм) ──
|
||||||
.formPlaceholder {
|
.formPlaceholder {
|
||||||
margin-top: $sp-6;
|
|
||||||
padding: $sp-8;
|
padding: $sp-8;
|
||||||
background: $white;
|
background: $white;
|
||||||
border: 1px solid $gray-200;
|
border: 1px solid $gray-200;
|
||||||
@@ -44,7 +44,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
margin-top: $sp-6;
|
|
||||||
padding: $sp-8;
|
padding: $sp-8;
|
||||||
color: $gray-500;
|
color: $gray-500;
|
||||||
font-size: $fs-sm;
|
font-size: $fs-sm;
|
||||||
|
|||||||
@@ -1,41 +1,40 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './SubmitFeature.module.scss';
|
import styles from './SubmitFeature.module.scss';
|
||||||
import { Icon } from '../../shared/ui/Icon/Icon';
|
import { Icon } from '../../shared/ui/Icon/Icon';
|
||||||
import { CategoryTabs } from '../../components/CategoryTabs/CategoryTabs';
|
|
||||||
import { RequestGrid } from '../../components/RequestGrid/RequestGrid';
|
import { RequestGrid } from '../../components/RequestGrid/RequestGrid';
|
||||||
import { CATEGORIES, DEFAULT_CATEGORY_ID } from '../../data/categories';
|
|
||||||
import { getRequestsByCategory, REQUEST_TYPES } from '../../data/requests';
|
import { getRequestsByCategory, REQUEST_TYPES } from '../../data/requests';
|
||||||
import { getFormComponent } from '../../forms/registry';
|
import { getFormComponent } from '../../forms/registry';
|
||||||
import { IRequestType } from '../../models/types';
|
import { IRequestType } from '../../models/types';
|
||||||
|
import { emitRequestsChanged } from '../../shared/requestEvents';
|
||||||
|
|
||||||
export interface ISubmitFeatureProps {
|
export interface ISubmitFeatureProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
|
/** Активная категория. Вкладки теперь рендерятся на уровне портала (RequestPortal),
|
||||||
|
* чтобы оставаться на экране и в режиме согласования — эта фича их только слушает. */
|
||||||
|
activeCategoryId: string;
|
||||||
/** Открыть сразу форму с предзаполнением (производная заявка), минуя плитки. */
|
/** Открыть сразу форму с предзаполнением (производная заявка), минуя плитки. */
|
||||||
initialForm?: { formKey: string; sourceId: number };
|
initialForm?: { formKey: string; sourceId: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Фича «Подать заявку»: категории → плитки → беспоук-форма.
|
// Фича «Подать заявку»: плитки → беспоук-форма (категории/вкладки — в RequestPortal).
|
||||||
// Владелец: автор портала. Живёт целиком в features/submit/**.
|
// Владелец: автор портала. Живёт целиком в features/submit/**.
|
||||||
const SubmitFeature: React.FC<ISubmitFeatureProps> = ({ context, initialForm }) => {
|
const SubmitFeature: React.FC<ISubmitFeatureProps> = ({ context, activeCategoryId, initialForm }) => {
|
||||||
// Если пришли по ссылке «создать на основе» — сразу открываем нужную форму.
|
// Если пришли по ссылке «создать на основе» — сразу открываем нужную форму.
|
||||||
const initialSelected = useMemo(
|
const initialSelected = useMemo(
|
||||||
() => (initialForm ? REQUEST_TYPES.filter((r) => r.id === initialForm.formKey)[0] ?? null : null),
|
() => (initialForm ? REQUEST_TYPES.filter((r) => r.id === initialForm.formKey)[0] ?? null : null),
|
||||||
[initialForm]
|
[initialForm]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [activeCategoryId, setActiveCategoryId] = useState<string>(
|
|
||||||
initialSelected ? initialSelected.categoryId : DEFAULT_CATEGORY_ID
|
|
||||||
);
|
|
||||||
const [selectedRequest, setSelectedRequest] = useState<IRequestType | null>(initialSelected);
|
const [selectedRequest, setSelectedRequest] = useState<IRequestType | null>(initialSelected);
|
||||||
|
|
||||||
const requests = getRequestsByCategory(activeCategoryId);
|
// Смена категории (вкладка сверху) — возвращаемся к списку плиток.
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedRequest((prev) => (prev && prev.categoryId !== activeCategoryId ? null : prev));
|
||||||
|
}, [activeCategoryId]);
|
||||||
|
|
||||||
const handleCategoryChange = (categoryId: string): void => {
|
const requests = getRequestsByCategory(activeCategoryId);
|
||||||
setActiveCategoryId(categoryId);
|
|
||||||
setSelectedRequest(null); // при смене категории возвращаемся к списку плиток
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBack = (): void => setSelectedRequest(null);
|
const handleBack = (): void => setSelectedRequest(null);
|
||||||
|
|
||||||
@@ -62,19 +61,19 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({ context, initialForm })
|
|||||||
// Ленивый рендер формы: её код подтягивается только сейчас, при открытии.
|
// Ленивый рендер формы: её код подтягивается только сейчас, при открытии.
|
||||||
return (
|
return (
|
||||||
<React.Suspense fallback={<div className={styles.loading}>Загрузка формы…</div>}>
|
<React.Suspense fallback={<div className={styles.loading}>Загрузка формы…</div>}>
|
||||||
<FormComponent requestType={request} context={context} onCancel={handleBack} sourceId={sourceId} />
|
<FormComponent
|
||||||
|
requestType={request}
|
||||||
|
context={context}
|
||||||
|
onCancel={handleBack}
|
||||||
|
onSaved={emitRequestsChanged}
|
||||||
|
sourceId={sourceId}
|
||||||
|
/>
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<CategoryTabs
|
|
||||||
categories={CATEGORIES}
|
|
||||||
activeId={activeCategoryId}
|
|
||||||
onChange={handleCategoryChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{selectedRequest ? (
|
{selectedRequest ? (
|
||||||
renderSelected(selectedRequest)
|
renderSelected(selectedRequest)
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ApprovalListService } from './approvalList.service';
|
|||||||
import { REQUEST_STATUS, APPROVAL_PHASE } from './models/statuses';
|
import { REQUEST_STATUS, APPROVAL_PHASE } from './models/statuses';
|
||||||
import { IApprovalRow } from './models/IApprovalRow';
|
import { IApprovalRow } from './models/IApprovalRow';
|
||||||
import { ISiteUser } from './models/ISiteUser';
|
import { ISiteUser } from './models/ISiteUser';
|
||||||
|
import { emitRequestsChanged } from '../requestEvents';
|
||||||
|
|
||||||
export type ApproverDecision = 'approve' | 'return' | 'reject';
|
export type ApproverDecision = 'approve' | 'return' | 'reject';
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ export class ApproverService {
|
|||||||
const rows = await this.approval.getForRequest(requestId);
|
const rows = await this.approval.getForRequest(requestId);
|
||||||
const requestStatus = this.computeRequestStatus(rows);
|
const requestStatus = this.computeRequestStatus(rows);
|
||||||
await this.updateRequestStatus(requestId, requestStatus);
|
await this.updateRequestStatus(requestId, requestStatus);
|
||||||
|
emitRequestsChanged();
|
||||||
return requestStatus;
|
return requestStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ export interface IRequestType {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
pageUrl: string;
|
pageUrl: string;
|
||||||
|
/** Серверный относительный URL списка данных (напр. "/sites/requests/Lists/List1"). undefined, пока не настроено. */
|
||||||
|
listId?: string;
|
||||||
|
/** Абсолютный URL сайта, где живёт этот список. undefined, пока не настроено. */
|
||||||
|
siteUrl?: string;
|
||||||
|
section: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обёртка ответа API (все ответы приходят в поле data).
|
// Обёртка ответа API (все ответы приходят в поле data).
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Общее событие «список заявок изменился»: создана новая заявка или принято
|
||||||
|
// решение согласующего. Панель «Текущие заявки» (features/myRequests) подписана
|
||||||
|
// и перечитывает данные. Через window.dispatchEvent — проще шины состояния,
|
||||||
|
// достаточно для нечастых событий (сохранение формы, решение согласующего).
|
||||||
|
const EVENT_NAME = 'rp-requests-changed';
|
||||||
|
|
||||||
|
export function emitRequestsChanged(): void {
|
||||||
|
window.dispatchEvent(new Event(EVENT_NAME));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подписка на изменения. Возвращает функцию отписки (вызвать в cleanup эффекта). */
|
||||||
|
export function onRequestsChanged(handler: () => void): () => void {
|
||||||
|
window.addEventListener(EVENT_NAME, handler);
|
||||||
|
return () => window.removeEventListener(EVENT_NAME, handler);
|
||||||
|
}
|
||||||
@@ -1,18 +1,33 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
// Маршрутизация портала по параметрам URL.
|
// Маршрутизация портала по параметрам URL.
|
||||||
//
|
//
|
||||||
// Обычный режим (нет параметров) — подача заявок (features/submit).
|
// Раздел бокового меню — rpnav: 'new' (подать заявку, по умолчанию),
|
||||||
// Режим согласующего — переход по ссылке из потока Power Automate:
|
// 'approvals' (ждут моего решения), 'own' (мои заявки).
|
||||||
|
// Открытая заявка — переход по ссылке из потока Power Automate или клик по
|
||||||
|
// строке таблицы:
|
||||||
// …/СтраницаПортала.aspx?rpmode=approve&rptype=<форма>&rpid=<RowID>
|
// …/СтраницаПортала.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
|
// Префикс rp (request-portal): имена id/mode/type зарезервированы SharePoint
|
||||||
// (напр. ?id= он трактует как навигацию к элементу → «Элемент не существует»),
|
// (напр. ?id= он трактует как навигацию к элементу → «Элемент не существует»),
|
||||||
// поэтому используем собственные, неконфликтные имена параметров.
|
// поэтому используем собственные, неконфликтные имена параметров.
|
||||||
|
//
|
||||||
|
// Навигация внутри портала идёт через navigate() — это history.pushState без
|
||||||
|
// перезагрузки страницы. useRoute() подписан и на popstate (кнопка «назад»),
|
||||||
|
// и на её собственное событие, поэтому реагирует на оба способа смены URL.
|
||||||
|
|
||||||
export type PortalMode = 'submit' | 'approve' | 'create';
|
export type PortalMode = 'submit' | 'approve' | 'create';
|
||||||
|
|
||||||
|
/** Раздел бокового меню. */
|
||||||
|
export type NavSection = 'new' | 'approvals' | 'own';
|
||||||
|
|
||||||
export interface IRoute {
|
export interface IRoute {
|
||||||
mode: PortalMode;
|
mode: PortalMode;
|
||||||
|
/** Раздел бокового меню; сохраняется и когда поверх открыта заявка. */
|
||||||
|
nav: NavSection;
|
||||||
/** Ключ формы (тип заявки) — для 'approve' и 'create'. */
|
/** Ключ формы (тип заявки) — для 'approve' и 'create'. */
|
||||||
formKey?: string;
|
formKey?: string;
|
||||||
/** Id заявки в списке данных — для 'approve'. */
|
/** Id заявки в списке данных — для 'approve'. */
|
||||||
@@ -21,23 +36,88 @@ export interface IRoute {
|
|||||||
sourceId?: number;
|
sourceId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
export function parseRoute(search: string = window.location.search): IRoute {
|
||||||
const params = new URLSearchParams(search);
|
const params = new URLSearchParams(search);
|
||||||
const mode = params.get('rpmode');
|
const mode = params.get('rpmode');
|
||||||
|
const nav = parseNav(params.get('rpnav') ?? undefined);
|
||||||
const formKey = params.get('rptype') ?? undefined;
|
const formKey = params.get('rptype') ?? undefined;
|
||||||
const idRaw = params.get('rpid');
|
const idRaw = params.get('rpid');
|
||||||
const itemId = idRaw ? Number(idRaw) : undefined;
|
const itemId = idRaw ? Number(idRaw) : undefined;
|
||||||
const sourceRaw = params.get('rpsource');
|
const sourceRaw = params.get('rpsource');
|
||||||
const sourceId = sourceRaw ? Number(sourceRaw) : undefined;
|
const sourceId = sourceRaw ? Number(sourceRaw) : undefined;
|
||||||
|
|
||||||
// Режим согласующего — при полном наборе параметров.
|
// Открытая заявка (панель поверх раздела) — при полном наборе параметров.
|
||||||
if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) {
|
if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) {
|
||||||
return { mode: 'approve', formKey, itemId };
|
return { mode: 'approve', nav, formKey, itemId };
|
||||||
}
|
}
|
||||||
// Создание производной заявки с предзаполнением из исходной.
|
// Создание производной заявки с предзаполнением из исходной.
|
||||||
if (formKey && sourceId && !isNaN(sourceId)) {
|
if (formKey && sourceId && !isNaN(sourceId)) {
|
||||||
return { mode: 'create', formKey, sourceId };
|
return { mode: 'create', nav: 'new', formKey, sourceId };
|
||||||
}
|
}
|
||||||
return { mode: 'submit' };
|
return { mode: 'submit', nav };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Собрать 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.nav !== 'new') {
|
||||||
|
params.set('rpnav', route.nav);
|
||||||
|
}
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = params.toString();
|
||||||
|
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());
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onChange = (): void => setRoute(parseRoute());
|
||||||
|
window.addEventListener('popstate', onChange);
|
||||||
|
window.addEventListener(ROUTE_CHANGE_EVENT, onChange);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('popstate', onChange);
|
||||||
|
window.removeEventListener(ROUTE_CHANGE_EVENT, onChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return route;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user