diff --git a/src/webparts/requestPortal/components/RequestPortal.module.scss b/src/webparts/requestPortal/components/RequestPortal.module.scss index 646caaa..ba8e39d 100644 --- a/src/webparts/requestPortal/components/RequestPortal.module.scss +++ b/src/webparts/requestPortal/components/RequestPortal.module.scss @@ -52,6 +52,11 @@ position: sticky; top: $sp-4; align-self: stretch; + transition: flex-basis .2s; +} + +.sideCollapsed { + flex-basis: 44px; } .tabsWrap { diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 99d9aea..e993475 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -23,6 +23,7 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context .join(' '); const route = useRoute(); + const [panelCollapsed, setPanelCollapsed] = React.useState(false); const [activeCategoryId, setActiveCategoryId] = React.useState(() => { if (route.mode === 'create' && route.formKey) { @@ -32,19 +33,43 @@ 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]); + return (
-
- +
+ setPanelCollapsed((v) => !v)} + />
{ + setActiveCategoryId(id); + // Клик по вкладке всегда возвращает к списку плиток: в режиме + // подачи это уже делает сам SubmitFeature (сброс выбранной формы + // при смене категории), а в режиме согласования (заявка открыта + // из «Текущие заявки») route сам не меняется — выходим явно, + // чтобы поведение вкладок было одинаковым в обоих случаях. + if (route.mode === 'approve') { + navigate({ mode: 'submit' }); + } + }} />
{route.mode === 'approve' && route.formKey && route.itemId ? ( diff --git a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss index c8773e2..b5f2983 100644 --- a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss +++ b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss @@ -14,16 +14,68 @@ } .header { + display: flex; + align-items: center; + justify-content: space-between; + gap: $sp-2; padding: $sp-4 $sp-5; border-bottom: 1px solid $gray-200; } +.collapseToggle { + flex-shrink: 0; + display: inline-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 { font-family: $font-serif; font-size: 18px; color: $navy-900; } +.searchWrap { + padding: $sp-3 $sp-5 0; +} + +.search { + width: 100%; + padding: 8px 12px; + 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; + } +} + .body { flex: 1; overflow-y: auto; @@ -56,16 +108,53 @@ margin-bottom: $sp-4; } -.group + .group { - margin-top: $sp-5; +.pager { + display: flex; + align-items: center; + gap: 4px; + margin-top: $sp-3; + flex-wrap: wrap; } -.groupTitle { - font-family: $font-serif; - font-size: $fs-label; - font-weight: 600; - color: $navy-800; - margin-bottom: $sp-2; +.pagerBtn { + min-width: 24px; + height: 24px; + padding: 0 6px; + border: 1px solid $gray-200; + border-radius: $r-sm; + 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; } .list { @@ -103,6 +192,14 @@ overflow: hidden; } +.itemType { + color: $gold-600; + font-size: $fs-caption; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .03em; +} + .itemTitle { color: $gray-900; font-size: $fs-sm; diff --git a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx index 0d278d2..44c5363 100644 --- a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx +++ b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx @@ -1,33 +1,21 @@ import * as React from 'react'; import type { WebPartContext } from '@microsoft/sp-webpart-base'; -import { getMyRequests } from './myRequests.service'; +import { getLinkedTypes, getPendingApproval, searchOwnRequests } from './myRequests.service'; import { IMyRequest } from './models/IMyRequest'; -import { REQUEST_STATUS } from '../../shared/approval'; +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; } -interface IGroup { - requestTypeName: string; - rows: IMyRequest[]; -} - -function groupByType(rows: IMyRequest[]): IGroup[] { - const order: string[] = []; - const map: Record = {}; - rows.forEach((r) => { - if (!map[r.requestTypeName]) { - map[r.requestTypeName] = []; - order.push(r.requestTypeName); - } - map[r.requestTypeName].push(r); - }); - return order.map((name) => ({ requestTypeName: name, rows: map[name] })); -} +const PAGE_SIZE = 20; function formatDate(iso: string): string { if (!iso) return ''; @@ -74,6 +62,7 @@ const RequestItem: React.FC<{ r: IMyRequest; variant: 'own' | 'approval' }> = ({ }} >
+ {r.requestTypeName} {r.title || `#${r.id}`} {variant === 'approval' && ( @@ -89,50 +78,206 @@ const RequestItem: React.FC<{ r: IMyRequest; variant: 'own' | 'approval' }> = ({ ); -const RequestGroups: React.FC<{ rows: IMyRequest[]; variant: 'own' | 'approval' }> = ({ - rows, - variant, -}) => ( - <> - {groupByType(rows).map((group) => ( -
-
{group.requestTypeName}
-
    - {group.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 ( + <> +
    + {pageRows.map((r) => ( + + ))} +
+ + + ); +}; + +// «Созданы вами»: один общий постраничный поиск по всем типам сразу через +// SharePoint Search REST API (см. myRequests.service.searchOwnRequests) — +// единая пагинация, без разбивки списка по типам заявок. +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; + searchOwnRequests(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); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [context, types, query, page]); + + if (error) { + return
Не удалось загрузить: {error}
; + } + + if (totalRows === undefined) { + return
Загрузка…
; + } + if (totalRows === 0) { + return
Вы ещё не создавали заявок.
; + } + + const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE)); + + return ( + <> +
    + {rows.map((r) => ( + + ))} +
+ + + ); +}; // Постоянная боковая панель «Текущие заявки»: заявки, созданные текущим // пользователем, и заявки, ждущие сейчас его решения как согласующего — -// по всем типам заявок, известным approval-route API. +// по всем типам заявок, известным approval-route API, в двух общих списках +// без разбивки по типам (тип виден на самой карточке). // Владелец: второй разработчик. Вся логика/данные — внутри этой папки. // Общий UI-кит (shared/ui), pnpjs и config можно импортировать, но НЕ менять. // -// Перечитывает данные по событию rp-requests-changed (shared/requestEvents) — -// оно шлётся после сохранения формы (SubmitFeature) и после решения -// согласующего (ApproverService.decide), так что панель обновляется сама, -// без перезагрузки страницы. +// «Ждёт вашего решения» перечитывается по событию rp-requests-changed +// (shared/requestEvents) — оно шлётся после сохранения формы и после решения +// согласующего. «Созданы вами» идёт через SharePoint Search (см. +// myRequests.service) постранично — намеренно НЕ обновляется по этому +// событию: только что созданная заявка появится там, когда её проиндексирует +// поиск (задержка приемлема). // -// Диагностика по типам заявок (listId/siteUrl/rowCount/error) в UI не выводится — -// см. console.table в devtools при загрузке панели. -const MyRequestsPanel: React.FC = ({ context }) => { - const [rows, setRows] = React.useState(undefined); +// Поиск по названию: «Ждёт вашего решения» фильтруется на клиенте (данные уже +// загружены целиком), «Созданы вами» — реальный запрос к Search REST API +// (может слегка отставать от только что созданных/переименованных заявок). +// +// Диагностика (listId/siteUrl/rowCount/error, KQL-запросы) в 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 => { - getMyRequests(context) + getPendingApproval(context) .then((result) => { if (cancelled) return; - setRows(result.rows); - setError(''); + setPendingRows(result.rows); console.table(result.debug); }) .catch((e) => { @@ -148,32 +293,66 @@ const MyRequestsPanel: React.FC = ({ context }) => { }; }, [context]); - const pendingApproval = rows ? rows.filter((r) => r.kind === 'approval') : []; - const own = rows ? rows.filter((r) => r.kind === 'own') : []; + 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 (