From adf578182b1f072028eed37fd74dea722d8c8a33 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 4 Aug 2026 16:59:12 +0500 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9?= =?UTF-8?q?=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/Drawer/Drawer.module.scss | 83 ++++ .../components/Drawer/Drawer.tsx | 39 ++ .../components/RequestPortal.module.scss | 39 +- .../components/RequestPortal.tsx | 133 +++--- .../components/SideNav/SideNav.module.scss | 119 ++++++ .../components/SideNav/SideNav.tsx | 75 ++++ .../features/myRequests/MyRequestsPanel.tsx | 384 ------------------ ...ule.scss => RequestsTableView.module.scss} | 291 ++++++------- .../features/myRequests/RequestsTableView.tsx | 283 +++++++++++++ .../features/submit/SubmitFeature.module.scss | 7 +- src/webparts/requestPortal/shared/routing.ts | 84 ++-- 11 files changed, 865 insertions(+), 672 deletions(-) create mode 100644 src/webparts/requestPortal/components/Drawer/Drawer.module.scss create mode 100644 src/webparts/requestPortal/components/Drawer/Drawer.tsx create mode 100644 src/webparts/requestPortal/components/SideNav/SideNav.module.scss create mode 100644 src/webparts/requestPortal/components/SideNav/SideNav.tsx delete mode 100644 src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx rename src/webparts/requestPortal/features/myRequests/{MyRequestsPanel.module.scss => RequestsTableView.module.scss} (50%) create mode 100644 src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx diff --git a/src/webparts/requestPortal/components/Drawer/Drawer.module.scss b/src/webparts/requestPortal/components/Drawer/Drawer.module.scss new file mode 100644 index 0000000..828ed4a --- /dev/null +++ b/src/webparts/requestPortal/components/Drawer/Drawer.module.scss @@ -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; } +} diff --git a/src/webparts/requestPortal/components/Drawer/Drawer.tsx b/src/webparts/requestPortal/components/Drawer/Drawer.tsx new file mode 100644 index 0000000..7406628 --- /dev/null +++ b/src/webparts/requestPortal/components/Drawer/Drawer.tsx @@ -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 = ({ 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 ( +
+ {/* Затемнение — не кнопка (внутри панели свои интерактивные элементы), + поэтому клик обрабатываем на этом слое напрямую. */} + + ); +}; diff --git a/src/webparts/requestPortal/components/RequestPortal.module.scss b/src/webparts/requestPortal/components/RequestPortal.module.scss index ba8e39d..bd3c4ad 100644 --- a/src/webparts/requestPortal/components/RequestPortal.module.scss +++ b/src/webparts/requestPortal/components/RequestPortal.module.scss @@ -32,13 +32,13 @@ font-family: $font-sans; } -// Двухколоночная раскладка: формы (main) + постоянная панель (side). +// Раскладка: боковое меню (side) + основная область (main). .layout { display: flex; align-items: flex-start; gap: $sp-6; width: 100%; - max-width: 1400px; + max-width: 1600px; margin: 0 auto; } @@ -47,50 +47,27 @@ min-width: 0; // чтобы контент не распирал колонку } +// Ширину задаёт само меню (SideNav сворачивается) — колонка просто под него. .side { - flex: 0 0 380px; + flex: 0 0 auto; position: sticky; top: $sp-4; - align-self: stretch; - transition: flex-basis .2s; -} - -.sideCollapsed { - flex-basis: 44px; } +// Та же ширина, что у контента подачи (SubmitFeature .content) — чтобы вкладки +// не уезжали шире формы под ними. .tabsWrap { + max-width: 1080px; margin-bottom: $sp-6; } -// Возврат из режима согласования (approve) к списку заявок (SubmitFeature). -.backButton { - display: inline-flex; - align-items: center; - gap: $sp-2; - margin-bottom: $sp-4; - padding: 0; - border: 0; - background: transparent; - color: $navy-700; - font-family: $font-sans; - font-size: $fs-sm; - cursor: pointer; - transition: color .18s; - - &:hover { - color: $gold-600; - } -} - -// На узких экранах панель уходит под формы. +// На узких экранах меню уходит наверх, в одну строку. @media (max-width: 900px) { .layout { flex-direction: column; } .side { - flex-basis: auto; width: 100%; position: static; } diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index e993475..0855574 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -2,28 +2,33 @@ import * as React from 'react'; import styles from './RequestPortal.module.scss'; import type { IRequestPortalProps } from './IRequestPortalProps'; import { IconSprite } from '../shared/ui/Icon/IconSprite'; -import { Icon } from '../shared/ui/Icon/Icon'; -import { navigate, useRoute } from '../shared/routing'; +import { navigate, useRoute, NavSection } from '../shared/routing'; +import { onRequestsChanged } from '../shared/requestEvents'; +import { SideNav } from './SideNav/SideNav'; +import { Drawer } from './Drawer/Drawer'; import { CategoryTabs } from './CategoryTabs/CategoryTabs'; import { CATEGORIES, DEFAULT_CATEGORY_ID } from '../data/categories'; import { REQUEST_TYPES } from '../data/requests'; import SubmitFeature from '../features/submit/SubmitFeature'; import ApproveFeature from '../features/approve/ApproveFeature'; -import MyRequestsPanel from '../features/myRequests/MyRequestsPanel'; +import { RequestsTableView } from '../features/myRequests/RequestsTableView'; +import { getPendingApproval } from '../features/myRequests/myRequests.service'; -// Оболочка портала. Логики фич тут нет — файл общий, меняется редко. -// Роутинг по URL: mode=approve&type=&id= → фича согласования, иначе — подача. +// Оболочка портала: тёмное боковое меню (разделы) + основная область. +// Логики фич тут нет — файл общий, меняется редко. // -// Вкладки категорий рендерятся здесь (а не внутри SubmitFeature), чтобы -// оставаться на экране и в режиме согласования (approve) — раньше режим -// согласования подменял всю область main и вкладки пропадали вместе с ней. +// Разделы (rpnav): 'new' — подача заявки (вкладки категорий + плитки + форма), +// 'approvals'/'own' — таблицы заявок. Клик по строке таблицы открывает заявку +// в выезжающей панели ПОВЕРХ раздела (rpmode=approve&rptype=&rpid=), поэтому +// раздел под ней сохраняется; та же ссылка приходит из потока Power Automate. const RequestPortal: React.FC = ({ hasTeamsContext, context }) => { const rootClasses = [styles.requestPortal, hasTeamsContext ? styles.teams : ''] .filter(Boolean) .join(' '); const route = useRoute(); - const [panelCollapsed, setPanelCollapsed] = React.useState(false); + const [navCollapsed, setNavCollapsed] = React.useState(false); + const [approvalsCount, setApprovalsCount] = React.useState(undefined); const [activeCategoryId, setActiveCategoryId] = React.useState(() => { if (route.mode === 'create' && route.formKey) { @@ -33,70 +38,88 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context return DEFAULT_CATEGORY_ID; }); - // Клик по заявке в «Текущие заявки» (или ссылка «создать на основе») — переключает - // вкладку категории на ту, к которой относится тип этой заявки, чтобы вкладки - // сверху совпадали с тем, что сейчас открыто в main. + // Счётчик на пункте «На согласовании». Обновляется по тому же событию, что и + // сами таблицы (создание заявки, решение согласующего). React.useEffect(() => { - if ((route.mode === 'approve' || route.mode === 'create') && route.formKey) { - const found = REQUEST_TYPES.filter((r) => r.id === route.formKey)[0]; - if (found) setActiveCategoryId(found.categoryId); - } - }, [route.mode, route.formKey]); + let cancelled = false; + + const load = (): void => { + getPendingApproval(context) + .then((r) => { + if (!cancelled) setApprovalsCount(r.rows.length); + }) + .catch(() => { + if (!cancelled) setApprovalsCount(undefined); + }); + }; + + load(); + const unsubscribe = onRequestsChanged(load); + return () => { + cancelled = true; + unsubscribe(); + }; + }, [context]); + + const goto = (nav: NavSection): void => navigate({ mode: 'submit', nav }); + const closeDrawer = (): void => navigate({ mode: 'submit', nav: route.nav }); return (
-
- setPanelCollapsed((v) => !v)} +
+ setNavCollapsed((v) => !v)} />
+
-
- { - setActiveCategoryId(id); - // Клик по вкладке всегда возвращает к списку плиток: в режиме - // подачи это уже делает сам SubmitFeature (сброс выбранной формы - // при смене категории), а в режиме согласования (заявка открыта - // из «Текущие заявки») route сам не меняется — выходим явно, - // чтобы поведение вкладок было одинаковым в обоих случаях. - if (route.mode === 'approve') { - navigate({ mode: 'submit' }); - } - }} - /> -
- {route.mode === 'approve' && route.formKey && route.itemId ? ( + {route.nav === 'new' ? ( <> - - +
+ +
+ ) : ( - + navigate({ + mode: 'approve', + nav: route.nav, + formKey: request.formKey, + itemId: request.id, + }) } /> )}
+ + {route.mode === 'approve' && route.formKey && route.itemId && ( + + + + )}
); }; diff --git a/src/webparts/requestPortal/components/SideNav/SideNav.module.scss b/src/webparts/requestPortal/components/SideNav/SideNav.module.scss new file mode 100644 index 0000000..8ea413b --- /dev/null +++ b/src/webparts/requestPortal/components/SideNav/SideNav.module.scss @@ -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; +} diff --git a/src/webparts/requestPortal/components/SideNav/SideNav.tsx b/src/webparts/requestPortal/components/SideNav/SideNav.tsx new file mode 100644 index 0000000..c46e177 --- /dev/null +++ b/src/webparts/requestPortal/components/SideNav/SideNav.tsx @@ -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 = ({ + activeId, + onChange, + approvalsCount, + collapsed, + onToggleCollapsed, +}) => { + const navClasses = [styles.nav, collapsed ? styles.navCollapsed : ''].filter(Boolean).join(' '); + + return ( + + ); +}; diff --git a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx deleted file mode 100644 index 91c840e..0000000 --- a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx +++ /dev/null @@ -1,384 +0,0 @@ -import * as React from 'react'; -import type { WebPartContext } from '@microsoft/sp-webpart-base'; -import { getLinkedTypes, getOwnRequests, getPendingApproval } from './myRequests.service'; -import { IMyRequest } from './models/IMyRequest'; -import { REQUEST_STATUS, IRequestType } from '../../shared/approval'; -import { navigate, routeUrl } from '../../shared/routing'; -import { onRequestsChanged } from '../../shared/requestEvents'; -import { Icon } from '../../shared/ui/Icon/Icon'; -import styles from './MyRequestsPanel.module.scss'; - -export interface IMyRequestsPanelProps { - context: WebPartContext; - /** Свёрнута ли панель (управляется снаружи — RequestPortal меняет ширину колонки). */ - collapsed?: boolean; - onToggleCollapsed?: () => void; -} - -const PAGE_SIZE = 20; - -function formatDate(iso: string): string { - if (!iso) return ''; - const d = new Date(iso); - const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`; - const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`; - return `${dd}.${mm}.${d.getFullYear()}`; -} - -function statusClass(status: string): string { - switch (status) { - case REQUEST_STATUS.approved: - return styles.statusApproved; - case REQUEST_STATUS.rejected: - return styles.statusRejected; - case REQUEST_STATUS.returned: - return styles.statusReturned; - case REQUEST_STATUS.onApproval: - return styles.statusOnApproval; - case REQUEST_STATUS.draft: - return styles.statusDraft; - default: - return styles.statusDefault; - } -} - -// «Ждёt вашего решения»: автор здесь — не вы, поэтому важна колонка «Автор», -// а статус не нужен (в этой секции все заявки по определению «на согласовании»). -// «Созданы вами»: автор — всегда вы, колонку не показываем, зато нужен статус. -const RequestRow: React.FC<{ r: IMyRequest; variant: 'own' | 'approval' }> = ({ r, variant }) => ( - - {r.requestTypeName} - - { - // Обычный левый клик — SPA-переход (без перезагрузки страницы). - // Ctrl/Cmd/Shift/среднюю кнопку — не трогаем, пусть браузер сам - // откроет в новой вкладке по href. - if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { - return; - } - e.preventDefault(); - navigate({ mode: 'approve', formKey: r.formKey, itemId: r.id }); - }} - > - {r.title || `#${r.id}`} - - - {formatDate(r.created)} - {variant === 'approval' && ( - - {[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'} - - )} - {variant === 'own' && ( - - {r.status || '—'} - - )} - -); - -const RequestsTable: React.FC<{ rows: IMyRequest[]; variant: 'own' | 'approval' }> = ({ rows, variant }) => ( - - - - - - - {variant === 'approval' && } - {variant === 'own' && } - - - - {rows.map((r) => ( - - ))} - -
ТипНазваниеДатаАвторСтатус
-); - -// Постраничная навигация: номера страниц + вперёд/назад. При большом -// количестве страниц показывает только соседние с текущей + первую/последнюю. -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 ( -
- - {from > 1 && ( - <> - - {from > 2 && …} - - )} - {numbers.map((n) => ( - - ))} - {to < totalPages && ( - <> - {to < totalPages - 1 && …} - - - )} - -
- ); -}; - -// «Ждёт вашего решения»: данные уже загружены целиком (алгоритм требует видеть -// весь маршрут заявки, чтобы понять, чья сейчас очередь) — один общий список -// по всем типам, пагинация — клиентская нарезка по этому списку. -const PendingSection: React.FC<{ rows: IMyRequest[] }> = ({ rows }) => { - const [page, setPage] = React.useState(1); - React.useEffect(() => setPage(1), [rows]); - - if (rows.length === 0) { - return
Нет заявок, ждущих вашего решения.
; - } - - const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE)); - const pageRows = rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); - - return ( - <> - - - - ); -}; - -// «Созданы вами»: один общий постраничный список по всем типам сразу через -// обычный REST (см. myRequests.service.getOwnRequests) — единая пагинация, -// без разбивки по типам, данные всегда актуальные (не из поискового индекса), -// поэтому секция перечитывается и по событию rp-requests-changed. -const OwnSection: React.FC<{ context: WebPartContext; types: IRequestType[]; query: string }> = ({ - context, - types, - query, -}) => { - const [page, setPage] = React.useState(1); - const [rows, setRows] = React.useState([]); - const [totalRows, setTotalRows] = React.useState(undefined); - const [error, setError] = React.useState(''); - - React.useEffect(() => setPage(1), [query]); - - React.useEffect(() => { - if (types.length === 0) { - setTotalRows(0); - return; - } - let cancelled = false; - - const load = (): void => { - getOwnRequests(context, types, query, page) - .then((result) => { - if (cancelled) return; - setRows(result.rows); - setTotalRows(result.totalRows); - setError(''); - }) - .catch((e) => { - if (!cancelled) setError((e as Error).message); - }); - }; - - load(); - const unsubscribe = onRequestsChanged(load); - return () => { - cancelled = true; - unsubscribe(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [context, types, query, page]); - - if (error) { - return
Не удалось загрузить: {error}
; - } - - if (totalRows === undefined) { - return
Загрузка…
; - } - if (totalRows === 0) { - return ( -
- {query ? `По запросу «${query}» ничего не найдено.` : 'Вы ещё не создавали заявок.'} -
- ); - } - - const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE)); - - return ( - <> - - - - ); -}; - -// Постоянная боковая панель «Текущие заявки»: заявки, созданные текущим -// пользователем, и заявки, ждущие сейчас его решения как согласующего — -// по всем типам заявок, известным approval-route API, в двух общих списках -// без разбивки по типам (тип виден на самой карточке). -// Владелец: второй разработчик. Вся логика/данные — внутри этой папки. -// Общий UI-кит (shared/ui), pnpjs и config можно импортировать, но НЕ менять. -// -// Обе секции перечитываются по событию rp-requests-changed -// (shared/requestEvents) — оно шлётся после сохранения формы и после решения -// согласующего, так что список обновляется сам, без перезагрузки страницы. -// -// Поиск по названию: «Ждёт вашего решения» фильтруется на клиенте (данные уже -// загружены целиком), «Созданы вами» — серверным OData-фильтром по Title. -// -// Диагностика (listId/siteUrl/rowCount/error) в UI не выводится — -// см. console.log/console.table в devtools при загрузке панели. -const MyRequestsPanel: React.FC = ({ context, collapsed, onToggleCollapsed }) => { - const [linkedTypes, setLinkedTypes] = React.useState(undefined); - const [pendingRows, setPendingRows] = React.useState(undefined); - const [error, setError] = React.useState(''); - const [query, setQuery] = React.useState(''); - - React.useEffect(() => { - let cancelled = false; - getLinkedTypes(context) - .then((types) => { - if (!cancelled) setLinkedTypes(types); - }) - .catch((e) => { - if (!cancelled) setError((e as Error).message); - }); - return () => { - cancelled = true; - }; - }, [context]); - - React.useEffect(() => { - let cancelled = false; - - const load = (): void => { - getPendingApproval(context) - .then((result) => { - if (cancelled) return; - setPendingRows(result.rows); - console.table(result.debug); - }) - .catch((e) => { - if (!cancelled) setError((e as Error).message); - }); - }; - - load(); - const unsubscribe = onRequestsChanged(load); - return () => { - cancelled = true; - unsubscribe(); - }; - }, [context]); - - const q = query.trim().toLowerCase(); - const filteredPending = - pendingRows && q ? pendingRows.filter((r) => r.title.toLowerCase().indexOf(q) !== -1) : pendingRows; - - const nothingLoaded = linkedTypes !== undefined && pendingRows !== undefined; - - if (collapsed) { - return ( - - ); - } - - return ( - - ); -}; - -export default MyRequestsPanel; diff --git a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss b/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss similarity index 50% rename from src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss rename to src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss index 2957c0a..420e609 100644 --- a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss @@ -1,66 +1,43 @@ @import '../../../../styles/design-tokens'; -// Постоянная боковая панель (всегда открыта). Раскладку колонок задаёт корень; -// здесь — только внутреннее оформление панели. -.panel { - display: flex; - flex-direction: column; - height: 100%; - background: $white; - border: 1px solid $gray-200; - border-radius: $r-lg; - box-shadow: $sh-1; - overflow: hidden; +.view { + width: 100%; } -.header { +.head { display: flex; align-items: center; justify-content: space-between; - gap: $sp-2; - padding: $sp-4 $sp-5; - border-bottom: 1px solid $gray-200; + gap: $sp-4; + flex-wrap: wrap; + margin-bottom: $sp-4; } -.collapseToggle { - flex-shrink: 0; - display: inline-flex; +.heading { + display: flex; align-items: center; - justify-content: center; - width: 26px; - height: 26px; - padding: 0; - border: 1px solid $gray-300; - border-radius: $r-sm; - background: $white; - color: $navy-700; - cursor: pointer; - transition: .16s; - - &:hover { - border-color: $gold-400; - color: $gold-600; - } -} - -.panelCollapsed { - align-items: center; - padding: $sp-3 0; -} - -.title { + gap: $sp-3; + margin: 0; font-family: $font-serif; - font-size: 18px; + font-size: $fs-page-title; + font-weight: 600; color: $navy-900; } -.searchWrap { - padding: $sp-3 $sp-5 0; +.count { + padding: 2px 10px; + border-radius: $r-full; + background: $navy-100; + color: $navy-700; + font-family: $font-sans; + font-size: $fs-label; + font-weight: 600; } .search { - width: 100%; - padding: 8px 12px; + width: 280px; + max-width: 100%; + padding: 9px 13px; border: 1px solid $gray-300; border-radius: $r-sm; background: $white; @@ -76,166 +53,111 @@ } } -.body { - flex: 1; - overflow-y: auto; - padding: $sp-5; - font-size: $fs-sm; -} - .empty, .error { + padding: $sp-6; + background: $white; + border: 1px solid $gray-200; + border-radius: $r-md; color: $gray-500; + font-size: $fs-sm; } .error { color: $red-500; + border-color: $red-500; + background: $red-50; } -.section + .section { - margin-top: $sp-6; - padding-top: $sp-6; - border-top: 1px solid $gray-200; -} - -.sectionTitle { - font-family: $font-sans; - font-size: $fs-caption; - font-weight: 600; - color: $gray-500; - text-transform: uppercase; - letter-spacing: .04em; - margin-bottom: $sp-4; -} - -.pager { - display: flex; - align-items: center; - gap: 4px; - margin-top: $sp-3; - flex-wrap: wrap; -} - -.pagerBtn { - min-width: 24px; - height: 24px; - padding: 0 6px; - border: 1px solid $gray-200; - border-radius: $r-sm; +.tableWrap { background: $white; - color: $gray-700; - font-family: $font-sans; - font-size: $fs-caption; - cursor: pointer; - transition: .16s; - - &:hover:not(:disabled) { - border-color: $gold-400; - color: $gold-600; - } - - &:disabled { - opacity: .4; - cursor: default; - } -} - -.pagerBtnActive { - background: $navy-800; - border-color: $navy-800; - color: $white; - - &:hover { - border-color: $navy-800 !important; - color: $white !important; - } -} - -.pagerEllipsis { - color: $gray-400; - font-size: $fs-caption; - padding: 0 2px; + border: 1px solid $gray-200; + border-radius: $r-md; + box-shadow: $sh-1; + overflow: hidden; } .table { width: 100%; border-collapse: collapse; - table-layout: fixed; - font-size: $fs-caption; + font-size: $fs-sm; th { text-align: left; - font-weight: 600; - color: $gray-500; - font-size: $fs-caption; - text-transform: uppercase; - letter-spacing: .03em; - padding: 6px 8px; + padding: 12px 16px; + background: $gray-50; border-bottom: 1px solid $gray-200; + color: $gray-500; + font-size: $fs-label; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .04em; + white-space: nowrap; } td { - // Высокие строки + перенос по словам: панель узкая, а длинные названия и - // e-mail не должны обрезаться — пусть занимают несколько строк. - height: 66px; - padding: $sp-3 8px; + padding: 14px 16px; border-bottom: 1px solid $gray-100; + color: $gray-900; vertical-align: middle; - white-space: normal; overflow-wrap: break-word; word-break: break-word; - line-height: 1.4; + } + + tbody tr:last-child td { + border-bottom: 0; } } +// Кликабельна вся строка (см. RequestsTableView — onClick на ). .row { + cursor: pointer; transition: background .16s; &:hover { background: $gold-50; } -} -.cellType { - width: 72px; - color: $gold-600; - font-weight: 600; -} - -.cellTitle { - // Ширину не задаём — делит оставшееся место с cellAuthor (table-layout: fixed). - min-width: 0; -} - -.titleLink { - display: block; - color: $gray-900; - text-decoration: none; - - &:hover { - color: $gold-600; - text-decoration: underline; + &:focus-visible { + outline: 2px solid $gold-500; + outline-offset: -2px; } } -.cellAuthor { +.colId { + width: 70px; + color: $gray-400; + white-space: nowrap; +} + +.colType { + width: 200px; + color: $navy-700; + font-weight: 500; +} + +.colTitle { + min-width: 240px; +} + +.colDate { + width: 110px; + color: $gray-500; + white-space: nowrap; +} + +.colAuthor { + width: 280px; color: $gray-500; } -.cellDate { - width: 64px; - color: $gray-400; -} - -.cellStatus { - width: 96px; +.colStatus { + width: 190px; } .status { - // inline-block, а не flex-элемент: бейдж живёт внутри ячейки таблицы и - // переносится по словам (напр. «Возвращена на доработку» в узкой колонке). display: inline-block; - padding: 3px 8px; + padding: 3px 10px; border-radius: $r-md; font-size: $fs-caption; font-weight: 500; @@ -271,3 +193,52 @@ background: $gray-100; color: $gray-500; } + +.pager { + display: flex; + align-items: center; + gap: $sp-1; + margin-top: $sp-4; + flex-wrap: wrap; +} + +.pagerBtn { + min-width: 30px; + height: 30px; + padding: 0 8px; + border: 1px solid $gray-200; + border-radius: $r-sm; + background: $white; + color: $gray-700; + font-family: $font-sans; + font-size: $fs-sm; + cursor: pointer; + transition: .16s; + + &:hover:not(:disabled) { + border-color: $gold-400; + color: $gold-600; + } + + &:disabled { + opacity: .4; + cursor: default; + } +} + +.pagerBtnActive { + background: $navy-800; + border-color: $navy-800; + color: $white; + + &:hover { + border-color: $navy-800 !important; + color: $white !important; + } +} + +.pagerEllipsis { + color: $gray-400; + font-size: $fs-sm; + padding: 0 4px; +} diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx new file mode 100644 index 0000000..16417b9 --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -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 ( +
+ + {from > 1 && ( + <> + + {from > 2 && …} + + )} + {numbers.map((n) => ( + + ))} + {to < totalPages && ( + <> + {to < totalPages - 1 && …} + + + )} + +
+ ); +}; + +// Таблица заявок. Клик в ЛЮБОМ месте строки открывает заявку (кликабельна вся +// строка, а не только название), поэтому строка — с onClick + tabIndex +// (доступ с клавиатуры: Enter/Space). +// +// «approvals»: автор — не текущий пользователь, поэтому нужна колонка «Автор»; +// статус не показываем — здесь все заявки по определению на согласовании. +// «own»: автор всегда текущий пользователь, зато важен статус. +const Table: React.FC<{ + rows: IMyRequest[]; + kind: 'approvals' | 'own'; + onOpen: (request: IMyRequest) => void; +}> = ({ rows, kind, onOpen }) => ( +
+ + + + + + + + {kind === 'approvals' && } + {kind === 'own' && } + + + + {rows.map((r) => ( + onOpen(r)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onOpen(r); + } + }} + > + + + + + {kind === 'approvals' && ( + + )} + {kind === 'own' && ( + + )} + + ))} + +
IDТипНазваниеДатаАвторСтатус
{r.id}{r.requestTypeName}{r.title || `#${r.id}`}{formatDate(r.created)} + {[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'} + + {r.status || '—'} +
+
+); + +// Раздел с таблицей заявок: поиск по названию + постраничный вывод. +// +// 'approvals' грузится целиком (алгоритм «чья сейчас очередь» требует видеть +// весь маршрут заявки), поиск и пагинация — на клиенте. +// 'own' идёт постранично через обычный REST (см. myRequests.service), поиск — +// серверным OData-фильтром по Title. +// +// Оба варианта перечитываются по событию rp-requests-changed +// (shared/requestEvents) — после создания заявки и после решения согласующего. +export const RequestsTableView: React.FC = ({ context, kind, onOpen }) => { + const [types, setTypes] = React.useState(undefined); + const [rows, setRows] = React.useState(undefined); + const [totalRows, setTotalRows] = React.useState(0); + const [page, setPage] = React.useState(1); + const [query, setQuery] = React.useState(''); + const [error, setError] = React.useState(''); + + 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 ( +
+
+

+ {heading} + {rows && {total}} +

+ setQuery(e.target.value)} + /> +
+ + {error &&
Не удалось загрузить заявки: {error}
} + {!error && rows === undefined &&
Загрузка…
} + {!error && rows && total === 0 && ( +
+ {q + ? `По запросу «${query}» ничего не найдено.` + : kind === 'approvals' + ? 'Нет заявок, ждущих вашего решения.' + : 'Вы ещё не создавали заявок.'} +
+ )} + {!error && rows && total > 0 && ( + <> + + + + )} + + ); +}; diff --git a/src/webparts/requestPortal/features/submit/SubmitFeature.module.scss b/src/webparts/requestPortal/features/submit/SubmitFeature.module.scss index 676670a..9b24de6 100644 --- a/src/webparts/requestPortal/features/submit/SubmitFeature.module.scss +++ b/src/webparts/requestPortal/features/submit/SubmitFeature.module.scss @@ -1,11 +1,12 @@ @import '../../../../styles/design-tokens'; -// Центрированная колонка контента. 1080px — ширина контента из дизайн-системы -// (как .sec в референсе): форма и плитки читаются аккуратно, не растягиваясь. +// Колонка контента. 1080px — ширина контента из дизайн-системы (как .sec в +// референсе): форма и плитки читаются аккуратно, не растягиваясь. Прижата +// влево (без margin: auto), иначе на широком экране между боковым меню и +// формой появлялась пустая полоса, а вкладки категорий сверху шли шире формы. .content { width: 100%; max-width: 1080px; - margin: 0 auto; } // ── Заглушка формы (до появления реальных форм) ── diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index 951ba0c..b075473 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -2,24 +2,32 @@ import * as React from 'react'; // Маршрутизация портала по параметрам URL. // -// Обычный режим (нет параметров) — подача заявок (features/submit). -// Режим согласующего — переход по ссылке из потока Power Automate: +// Раздел бокового меню — rpnav: 'new' (подать заявку, по умолчанию), +// 'approvals' (ждут моего решения), 'own' (мои заявки). +// Открытая заявка — переход по ссылке из потока Power Automate или клик по +// строке таблицы: // …/СтраницаПортала.aspx?rpmode=approve&rptype=<форма>&rpid= -// где rptype = ключ формы в реестре (forms/registry), rpid = RowID заявки в списке данных. +// где rptype = ключ формы в реестре (forms/registry), rpid = RowID заявки в +// списке данных. Заявка показывается в выезжающей панели ПОВЕРХ текущего +// раздела, поэтому rpnav сохраняется вместе с rpmode. // // Префикс rp (request-portal): имена id/mode/type зарезервированы SharePoint // (напр. ?id= он трактует как навигацию к элементу → «Элемент не существует»), // поэтому используем собственные, неконфликтные имена параметров. // -// Навигация внутри портала (напр. клик по заявке в «Мои заявки») идёт через -// navigate() — это history.pushState без перезагрузки страницы. useRoute() -// подписан и на popstate (кнопка «назад»), и на её собственное событие, -// поэтому реагирует на оба способа смены URL. +// Навигация внутри портала идёт через navigate() — это history.pushState без +// перезагрузки страницы. useRoute() подписан и на popstate (кнопка «назад»), +// и на её собственное событие, поэтому реагирует на оба способа смены URL. export type PortalMode = 'submit' | 'approve' | 'create'; +/** Раздел бокового меню. */ +export type NavSection = 'new' | 'approvals' | 'own'; + export interface IRoute { mode: PortalMode; + /** Раздел бокового меню; сохраняется и когда поверх открыта заявка. */ + nav: NavSection; /** Ключ формы (тип заявки) — для 'approve' и 'create'. */ formKey?: string; /** Id заявки в списке данных — для 'approve'. */ @@ -30,62 +38,45 @@ export interface IRoute { const ROUTE_CHANGE_EVENT = 'rp-route-change'; +function parseNav(raw: string | undefined): NavSection { + if (raw === 'approvals' || raw === 'own') return raw; + return 'new'; +} + /** Разобрать строку запроса (по умолчанию — из адреса страницы) в маршрут. */ export function parseRoute(search: string = window.location.search): IRoute { const params = new URLSearchParams(search); const mode = params.get('rpmode'); + const nav = parseNav(params.get('rpnav') ?? undefined); const formKey = params.get('rptype') ?? undefined; const idRaw = params.get('rpid'); const itemId = idRaw ? Number(idRaw) : undefined; const sourceRaw = params.get('rpsource'); const sourceId = sourceRaw ? Number(sourceRaw) : undefined; - // Режим согласующего — при полном наборе параметров. + // Открытая заявка (панель поверх раздела) — при полном наборе параметров. if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) { - return { mode: 'approve', formKey, itemId }; + return { mode: 'approve', nav, formKey, itemId }; } // Создание производной заявки с предзаполнением из исходной. if (formKey && sourceId && !isNaN(sourceId)) { - return { mode: 'create', formKey, sourceId }; + return { mode: 'create', nav: 'new', formKey, sourceId }; } - return { mode: 'submit' }; + return { mode: 'submit', nav }; } -/** - * Перейти на другой маршрут без перезагрузки страницы (SPA): обновляет - * query-параметры адресной строки через history.pushState и оповещает - * useRoute() своим событием (popstate на pushState сам не срабатывает). - */ -export function navigate(route: IRoute): void { +// Собрать query-строку маршрута, сохранив посторонние параметры страницы. +function buildQuery(route: IRoute): string { const params = new URLSearchParams(window.location.search); params.delete('rpmode'); + params.delete('rpnav'); params.delete('rptype'); params.delete('rpid'); params.delete('rpsource'); - if (route.mode === 'approve' && route.formKey && route.itemId) { - params.set('rpmode', 'approve'); - params.set('rptype', route.formKey); - params.set('rpid', String(route.itemId)); - } else if (route.mode === 'create' && route.formKey && route.sourceId) { - params.set('rptype', route.formKey); - params.set('rpsource', String(route.sourceId)); + if (route.nav !== 'new') { + params.set('rpnav', route.nav); } - - const query = params.toString(); - const url = `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`; - window.history.pushState({}, '', url); - window.dispatchEvent(new Event(ROUTE_CHANGE_EVENT)); -} - -/** Ссылка на маршрут (для href — открытие в новой вкладке, копирование и т.п.). */ -export function routeUrl(route: IRoute): string { - const params = new URLSearchParams(window.location.search); - params.delete('rpmode'); - params.delete('rptype'); - params.delete('rpid'); - params.delete('rpsource'); - if (route.mode === 'approve' && route.formKey && route.itemId) { params.set('rpmode', 'approve'); params.set('rptype', route.formKey); @@ -99,6 +90,21 @@ export function routeUrl(route: IRoute): string { return `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`; } +/** + * Перейти на другой маршрут без перезагрузки страницы (SPA): обновляет + * query-параметры адресной строки через history.pushState и оповещает + * useRoute() своим событием (popstate на pushState сам не срабатывает). + */ +export function navigate(route: IRoute): void { + window.history.pushState({}, '', buildQuery(route)); + window.dispatchEvent(new Event(ROUTE_CHANGE_EVENT)); +} + +/** Ссылка на маршрут (для href — открытие в новой вкладке, копирование и т.п.). */ +export function routeUrl(route: IRoute): string { + return buildQuery(route); +} + /** Текущий маршрут, реактивно обновляется по navigate() и кнопке «назад». */ export function useRoute(): IRoute { const [route, setRoute] = React.useState(() => parseRoute());