Панель со списками заявок (#1)

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/1
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tnsu.kz>
Co-committed-by: gorbenko.pavel <gorbenko.pavel@tnsu.kz>
This commit was merged in pull request #1.
This commit is contained in:
2026-08-04 12:02:11 +00:00
committed by timur.gorodetskiy
parent ba4e3feee5
commit 0aa0a60e40
21 changed files with 1442 additions and 121 deletions
@@ -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;
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
gap: 14px;
margin-top: $sp-6;
}
.tile {
@@ -99,7 +98,6 @@
}
.empty {
margin-top: $sp-6;
color: $gray-500;
font-size: $fs-sm;
}
@@ -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,21 +47,27 @@
min-width: 0; // чтобы контент не распирал колонку
}
// Ширину задаёт само меню (SideNav сворачивается) — колонка просто под него.
.side {
flex: 0 0 380px;
flex: 0 0 auto;
position: sticky;
top: $sp-4;
align-self: stretch;
}
// На узких экранах панель уходит под формы.
// Та же ширина, что у контента подачи (SubmitFeature .content) — чтобы вкладки
// не уезжали шире формы под ними.
.tabsWrap {
max-width: 1080px;
margin-bottom: $sp-6;
}
// На узких экранах меню уходит наверх, в одну строку.
@media (max-width: 900px) {
.layout {
flex-direction: column;
}
.side {
flex-basis: auto;
width: 100%;
position: static;
}
@@ -2,41 +2,123 @@ import * as React from 'react';
import styles from './RequestPortal.module.scss';
import type { IRequestPortalProps } from './IRequestPortalProps';
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 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) готова как компонент, но НЕ смонтирована —
// её подключает второй разработчик, когда наполнит. Для этого:
// 1) import MyRequestsPanel from '../features/myRequests/MyRequestsPanel';
// 2) обернуть контент подачи в <div className={styles.layout}> с двумя колонками:
// <main className={styles.main}><SubmitFeature .../></main>
// <div className={styles.side}><MyRequestsPanel context={context} /></div>
// (классы layout/main/side уже есть в RequestPortal.module.scss)
// Разделы (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 = 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 (
<section className={rootClasses}>
<IconSprite />
{route.mode === 'approve' && route.formKey && route.itemId ? (
<ApproveFeature context={context} formKey={route.formKey} itemId={route.itemId} />
) : (
<SubmitFeature
context={context}
initialForm={
route.mode === 'create' && route.formKey && route.sourceId
? { formKey: route.formKey, sourceId: route.sourceId }
: undefined
}
/>
<div className={styles.layout}>
<div className={styles.side}>
<SideNav
activeId={route.nav}
onChange={goto}
approvalsCount={approvalsCount}
collapsed={navCollapsed}
onToggleCollapsed={() => setNavCollapsed((v) => !v)}
/>
</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>
);
@@ -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>
);
};