дозагрузка свежих данных
This commit is contained in:
@@ -12,6 +12,7 @@ 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 { SearchView } from '../features/myRequests/SearchView';
|
||||
import { getPendingApproval } from '../features/myRequests/myRequests.service';
|
||||
|
||||
// Оболочка портала: тёмное боковое меню (разделы) + основная область.
|
||||
@@ -39,14 +40,18 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
});
|
||||
|
||||
// Счётчик на пункте «На согласовании». Обновляется по тому же событию, что и
|
||||
// сами таблицы (создание заявки, решение согласующего).
|
||||
// сами таблицы (создание заявки, решение согласующего), и на монтировании
|
||||
// (в т.ч. на обновление страницы). Когда сама вкладка «На согласовании»
|
||||
// открыта, RequestsTableView сообщает точное число через onMyTurnCountChange
|
||||
// (ниже) — это перезаписывает значение отсюда, чтобы не было гонки между
|
||||
// двумя независимыми запросами одних и тех же данных.
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const load = (): void => {
|
||||
getPendingApproval(context)
|
||||
.then((r) => {
|
||||
if (!cancelled) setApprovalsCount(r.rows.length);
|
||||
if (!cancelled) setApprovalsCount(r.totalRows);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setApprovalsCount(undefined);
|
||||
@@ -108,6 +113,18 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : route.nav === 'search' ? (
|
||||
<SearchView
|
||||
context={context}
|
||||
onOpen={(request) =>
|
||||
navigate({
|
||||
mode: 'approve',
|
||||
nav: route.nav,
|
||||
formKey: request.formKey,
|
||||
itemId: request.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<RequestsTableView
|
||||
context={context}
|
||||
@@ -120,6 +137,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
itemId: request.id,
|
||||
})
|
||||
}
|
||||
onMyTurnCountChange={route.nav === 'approvals' ? setApprovalsCount : undefined}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -14,6 +14,7 @@ const ITEMS: INavItem[] = [
|
||||
{ id: 'new', title: 'Новая заявка', icon: 'plus' },
|
||||
{ id: 'approvals', title: 'На согласовании', icon: 'user-check' },
|
||||
{ id: 'own', title: 'Мои заявки', icon: 'clipboard' },
|
||||
{ id: 'search', title: 'Поиск', icon: 'search' },
|
||||
];
|
||||
|
||||
interface ISideNavProps {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.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,69 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Pager.module.scss';
|
||||
|
||||
export interface IPagerProps {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onChange: (page: number) => void;
|
||||
}
|
||||
|
||||
// Постраничная навигация: номера страниц + вперёд/назад. При большом
|
||||
// количестве страниц показывает только соседние с текущей + первую/последнюю.
|
||||
// Общий для всех разделов «Текущие заявки» (RequestsTableView, SearchView).
|
||||
export const Pager: React.FC<IPagerProps> = ({ 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>
|
||||
);
|
||||
};
|
||||
@@ -193,52 +193,3 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { IMyRequest } from './models/IMyRequest';
|
||||
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
|
||||
import { onRequestsChanged } from '../../shared/requestEvents';
|
||||
import { Pager } from './Pager';
|
||||
import styles from './RequestsTableView.module.scss';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
@@ -21,6 +22,10 @@ export interface IRequestsTableViewProps {
|
||||
kind: 'approvals' | 'own';
|
||||
/** Клик по строке — открыть заявку (в выезжающей панели). */
|
||||
onOpen: (request: IMyRequest) => void;
|
||||
/** Точное число «ждут решения СЕЙЧАС» (kind='approvals', без поиска) — для
|
||||
* счётчика в SideNav, чтобы не гонять для него отдельный параллельный
|
||||
* запрос, который мог бы разойтись по времени с тем, что видно в таблице. */
|
||||
onMyTurnCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
@@ -48,70 +53,6 @@ function statusClass(status: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Постраничная навигация: номера страниц + вперёд/назад. При большом
|
||||
// количестве страниц показывает только соседние с текущей + первую/последнюю.
|
||||
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).
|
||||
@@ -191,7 +132,12 @@ const Table: React.FC<{
|
||||
// Оба варианта перечитываются по событию rp-requests-changed
|
||||
// (shared/requestEvents) — после создания заявки и после решения согласующего.
|
||||
// Поиск идёт по индексу, поэтому свежая правка может проявиться не мгновенно.
|
||||
export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({ context, kind, onOpen }) => {
|
||||
export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
context,
|
||||
kind,
|
||||
onOpen,
|
||||
onMyTurnCountChange,
|
||||
}) => {
|
||||
const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
|
||||
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
|
||||
const [totalRows, setTotalRows] = React.useState<number>(0);
|
||||
@@ -240,6 +186,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({ context,
|
||||
setRows(r.rows);
|
||||
setTotalRows(r.totalRows);
|
||||
setError('');
|
||||
if (onMyTurnCountChange) onMyTurnCountChange(r.totalRows);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
@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: 420px;
|
||||
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;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
// Кликабельна вся строка (см. SearchView — 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: 170px;
|
||||
color: $navy-700;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.colTitle {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.colDate {
|
||||
width: 100px;
|
||||
color: $gray-500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.colAuthor {
|
||||
width: 220px;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.colStatus {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.colKind {
|
||||
width: 110px;
|
||||
color: $gray-400;
|
||||
font-size: $fs-caption;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import * as React from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { getLinkedTypes, searchMyRequests } from './myRequests.service';
|
||||
import { IMyRequest } from './models/IMyRequest';
|
||||
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
|
||||
import { onRequestsChanged } from '../../shared/requestEvents';
|
||||
import { Pager } from './Pager';
|
||||
import styles from './SearchView.module.scss';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export interface ISearchViewProps {
|
||||
context: WebPartContext;
|
||||
/** Клик по строке — открыть заявку (в выезжающей панели). */
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// В отличие от RequestsTableView (где по смыслу раздела нужно либо Автор,
|
||||
// либо Статус) — здесь смешаны «свои» и «где участвовал» заявки, поэтому
|
||||
// показываем обе колонки сразу, плюс небольшая метка «Автор/Согласование» —
|
||||
// чтобы было видно, откуда строка (kind), не только по автору.
|
||||
const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => void }> = ({ rows, 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>
|
||||
<th className={styles.colAuthor}>Автор</th>
|
||||
<th className={styles.colStatus}>Статус</th>
|
||||
<th className={styles.colKind}>Роль</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>
|
||||
<td className={styles.colAuthor}>
|
||||
{[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'}
|
||||
</td>
|
||||
<td className={styles.colStatus}>
|
||||
<span className={`${styles.status} ${statusClass(r.status)}`}>{r.status || '—'}</span>
|
||||
</td>
|
||||
<td className={styles.colKind}>{r.kind === 'own' ? 'Создал(а) я' : 'Участвую'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Вкладка «Поиск»: ищет сразу и среди своих заявок, и среди тех, где
|
||||
// пользователь участвует как согласующий (независимо от того, дошла ли уже
|
||||
// очередь) — единым списком, с колонками Автор и Статус одновременно (см.
|
||||
// searchMyRequests в myRequests.service.ts). Без введённого текста — пусто,
|
||||
// это не список «на экран по умолчанию», а именно поиск.
|
||||
//
|
||||
// Перечитывается по событию rp-requests-changed (после создания заявки и
|
||||
// после решения согласующего) — но т.к. это по большей части Search, свежая
|
||||
// правка может проявиться не мгновенно (частично компенсируется довеском
|
||||
// последних 10 минут через REST — см. myRequests.service.ts).
|
||||
export const SearchView: React.FC<ISearchViewProps> = ({ context, 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]);
|
||||
|
||||
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;
|
||||
if (query.trim().length === 0) {
|
||||
setRows(undefined);
|
||||
setTotalRows(0);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
const r = await searchMyRequests(context, types, query, page);
|
||||
if (cancelled) return;
|
||||
setRows(r.rows);
|
||||
setTotalRows(r.totalRows);
|
||||
setError('');
|
||||
} catch (e) {
|
||||
if (!cancelled) setError((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
load().catch(() => undefined);
|
||||
const unsubscribe = onRequestsChanged(() => {
|
||||
load().catch(() => undefined);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [context, types, query, page]);
|
||||
|
||||
const hasQuery = query.trim().length > 0;
|
||||
const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<section className={styles.view}>
|
||||
<div className={styles.head}>
|
||||
<h2 className={styles.heading}>
|
||||
Поиск
|
||||
{hasQuery && rows && <span className={styles.count}>{totalRows}</span>}
|
||||
</h2>
|
||||
<input
|
||||
type="search"
|
||||
className={styles.search}
|
||||
placeholder="Введите текст — ищем по своим заявкам и по тем, где вы участвуете…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.error}>Не удалось выполнить поиск: {error}</div>}
|
||||
{!error && !hasQuery && <div className={styles.empty}>Введите текст, чтобы начать поиск.</div>}
|
||||
{!error && hasQuery && rows === undefined && <div className={styles.empty}>Ищем…</div>}
|
||||
{!error && hasQuery && rows && totalRows === 0 && (
|
||||
<div className={styles.empty}>По запросу «{query}» ничего не найдено.</div>
|
||||
)}
|
||||
{!error && hasQuery && rows && totalRows > 0 && (
|
||||
<>
|
||||
<Table rows={rows} onOpen={onOpen} />
|
||||
<Pager page={page} totalPages={totalPages} onChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -625,6 +625,101 @@ export async function filterPendingByQuery(
|
||||
return freshMatches.concat(olderMatches);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Вкладка «Поиск»: ищет сразу по двум категориям — «Мои заявки» (я автор) и
|
||||
// «где я участвовал как согласующий» (involved, независимо от того, дошла ли
|
||||
// уже очередь) — и показывает их одним списком, с колонками Автор и Статус
|
||||
// одновременно (в отличие от «Мои заявки»/«На согласовании», где по смыслу
|
||||
// раздела одно из двух не нужно).
|
||||
//
|
||||
// Пагинация — клиентская поверх объединённого результата: «Мои заявки» уже
|
||||
// умеет по-настоящему постранично через Search, но «где участвовал» — нет
|
||||
// (см. filterPendingByQuery — там честного $skip/$top не сделать), так что
|
||||
// единой серверной пагинации на двух источниках сразу не построить. Для
|
||||
// сценария «искал что-то один раз» это приемлемо.
|
||||
export interface ISearchPage {
|
||||
rows: IMyRequest[];
|
||||
totalRows: number;
|
||||
}
|
||||
|
||||
// Как getOwnRequests, но без $skip/$top — сразу весь найденный срез (до
|
||||
// KQL_ID_LIMIT), с тем же довеском свежих (< 10 мин) заявок через REST.
|
||||
async function searchOwnAll(
|
||||
context: WebPartContext,
|
||||
types: IRequestType[],
|
||||
query: string
|
||||
): Promise<IMyRequest[]> {
|
||||
if (types.length === 0) return [];
|
||||
|
||||
const sp = spfi(types[0].siteUrl as string).using(SPFx(context));
|
||||
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
|
||||
const clauses = [
|
||||
`(${types.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`,
|
||||
`AuthorOWSUSER:"${claimsLogin}"`,
|
||||
];
|
||||
const text = freeTextClause(query);
|
||||
if (text) clauses.push(text);
|
||||
const kql = clauses.join(' AND ');
|
||||
|
||||
const results = await sp.search({
|
||||
Querytext: kql,
|
||||
RowLimit: KQL_ID_LIMIT,
|
||||
SelectProperties: ['ListItemID', 'Title', 'Path'],
|
||||
TrimDuplicates: false,
|
||||
});
|
||||
|
||||
console.log(`[Текущие заявки] Search «Поиск» (мои заявки): KQL = ${kql}, найдено: ${results.TotalRows}`);
|
||||
console.table(results.PrimarySearchResults);
|
||||
|
||||
const entries = hitsToEntries(results, types);
|
||||
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
|
||||
const searchRows = entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r);
|
||||
|
||||
const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t, query)))).reduce(
|
||||
(all, r) => all.concat(r),
|
||||
[] as IMyRequest[]
|
||||
);
|
||||
const searchKeys = new Set(searchRows.map((r) => `${r.requestTypeId}-${r.id}`));
|
||||
const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`));
|
||||
|
||||
return onlyFresh.concat(searchRows);
|
||||
}
|
||||
|
||||
export async function searchMyRequests(
|
||||
context: WebPartContext,
|
||||
types: IRequestType[],
|
||||
query: string,
|
||||
page: number
|
||||
): Promise<ISearchPage> {
|
||||
const q = query.trim();
|
||||
if (!q) return { rows: [], totalRows: 0 };
|
||||
|
||||
const [ownRows, involvedIdx] = await Promise.all([
|
||||
searchOwnAll(context, types, q),
|
||||
getPendingApprovalIndex(context, 'involved'),
|
||||
]);
|
||||
|
||||
const involvedFiltered = await filterPendingByQuery(context, involvedIdx.types, involvedIdx.rows, q);
|
||||
|
||||
// Заявка может оказаться и своей, и involved одновременно (сам себе
|
||||
// согласующий) — оставляем как 'own', не дублируем строку.
|
||||
const ownKeys = new Set(ownRows.map((r) => `${r.requestTypeId}-${r.id}`));
|
||||
const involvedOnly = involvedFiltered.filter((r) => !ownKeys.has(`${r.requestTypeId}-${r.id}`));
|
||||
|
||||
const merged = ownRows.concat(involvedOnly).sort((a, b) => b.created.localeCompare(a.created));
|
||||
const totalRows = merged.length;
|
||||
const pageRows = merged.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
// Автор нужен для 'approval'-строк текущей страницы — 'own' уже пришли с
|
||||
// автором (loadByIds/getFreshOwnRowsForType сами дочитывают Author).
|
||||
const needAuthor = pageRows.filter((r) => r.kind === 'approval');
|
||||
if (needAuthor.length > 0) {
|
||||
await hydratePendingAuthors(context, involvedIdx.types, needAuthor);
|
||||
}
|
||||
|
||||
return { rows: pageRows, totalRows };
|
||||
}
|
||||
|
||||
function toMyRequest(
|
||||
r: Record<string, unknown>,
|
||||
author: { EMail?: string; Title?: string } | undefined,
|
||||
|
||||
@@ -3,7 +3,8 @@ import * as React from 'react';
|
||||
// Маршрутизация портала по параметрам URL.
|
||||
//
|
||||
// Раздел бокового меню — rpnav: 'new' (подать заявку, по умолчанию),
|
||||
// 'approvals' (ждут моего решения), 'own' (мои заявки).
|
||||
// 'approvals' (ждут моего решения), 'own' (мои заявки), 'search' (поиск
|
||||
// сразу по обеим категориям — и по своим заявкам, и по тем, где участвовал).
|
||||
// Открытая заявка — переход по ссылке из потока Power Automate или клик по
|
||||
// строке таблицы:
|
||||
// …/СтраницаПортала.aspx?rpmode=approve&rptype=<форма>&rpid=<RowID>
|
||||
@@ -22,7 +23,7 @@ import * as React from 'react';
|
||||
export type PortalMode = 'submit' | 'approve' | 'create';
|
||||
|
||||
/** Раздел бокового меню. */
|
||||
export type NavSection = 'new' | 'approvals' | 'own';
|
||||
export type NavSection = 'new' | 'approvals' | 'own' | 'search';
|
||||
|
||||
export interface IRoute {
|
||||
mode: PortalMode;
|
||||
@@ -39,7 +40,7 @@ export interface IRoute {
|
||||
const ROUTE_CHANGE_EVENT = 'rp-route-change';
|
||||
|
||||
function parseNav(raw: string | undefined): NavSection {
|
||||
if (raw === 'approvals' || raw === 'own') return raw;
|
||||
if (raw === 'approvals' || raw === 'own' || raw === 'search') return raw;
|
||||
return 'new';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user