Панель со списками заявок (#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:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user