дозагрузка свежих данных
This commit is contained in:
@@ -12,6 +12,7 @@ import { REQUEST_TYPES } from '../data/requests';
|
|||||||
import SubmitFeature from '../features/submit/SubmitFeature';
|
import SubmitFeature from '../features/submit/SubmitFeature';
|
||||||
import ApproveFeature from '../features/approve/ApproveFeature';
|
import ApproveFeature from '../features/approve/ApproveFeature';
|
||||||
import { RequestsTableView } from '../features/myRequests/RequestsTableView';
|
import { RequestsTableView } from '../features/myRequests/RequestsTableView';
|
||||||
|
import { SearchView } from '../features/myRequests/SearchView';
|
||||||
import { getPendingApproval } from '../features/myRequests/myRequests.service';
|
import { getPendingApproval } from '../features/myRequests/myRequests.service';
|
||||||
|
|
||||||
// Оболочка портала: тёмное боковое меню (разделы) + основная область.
|
// Оболочка портала: тёмное боковое меню (разделы) + основная область.
|
||||||
@@ -39,14 +40,18 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Счётчик на пункте «На согласовании». Обновляется по тому же событию, что и
|
// Счётчик на пункте «На согласовании». Обновляется по тому же событию, что и
|
||||||
// сами таблицы (создание заявки, решение согласующего).
|
// сами таблицы (создание заявки, решение согласующего), и на монтировании
|
||||||
|
// (в т.ч. на обновление страницы). Когда сама вкладка «На согласовании»
|
||||||
|
// открыта, RequestsTableView сообщает точное число через onMyTurnCountChange
|
||||||
|
// (ниже) — это перезаписывает значение отсюда, чтобы не было гонки между
|
||||||
|
// двумя независимыми запросами одних и тех же данных.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const load = (): void => {
|
const load = (): void => {
|
||||||
getPendingApproval(context)
|
getPendingApproval(context)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (!cancelled) setApprovalsCount(r.rows.length);
|
if (!cancelled) setApprovalsCount(r.totalRows);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setApprovalsCount(undefined);
|
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
|
<RequestsTableView
|
||||||
context={context}
|
context={context}
|
||||||
@@ -120,6 +137,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
itemId: request.id,
|
itemId: request.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
onMyTurnCountChange={route.nav === 'approvals' ? setApprovalsCount : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const ITEMS: INavItem[] = [
|
|||||||
{ id: 'new', title: 'Новая заявка', icon: 'plus' },
|
{ id: 'new', title: 'Новая заявка', icon: 'plus' },
|
||||||
{ id: 'approvals', title: 'На согласовании', icon: 'user-check' },
|
{ id: 'approvals', title: 'На согласовании', icon: 'user-check' },
|
||||||
{ id: 'own', title: 'Мои заявки', icon: 'clipboard' },
|
{ id: 'own', title: 'Мои заявки', icon: 'clipboard' },
|
||||||
|
{ id: 'search', title: 'Поиск', icon: 'search' },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface ISideNavProps {
|
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;
|
background: $gray-100;
|
||||||
color: $gray-500;
|
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 { IMyRequest } from './models/IMyRequest';
|
||||||
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
|
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
|
||||||
import { onRequestsChanged } from '../../shared/requestEvents';
|
import { onRequestsChanged } from '../../shared/requestEvents';
|
||||||
|
import { Pager } from './Pager';
|
||||||
import styles from './RequestsTableView.module.scss';
|
import styles from './RequestsTableView.module.scss';
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
@@ -21,6 +22,10 @@ export interface IRequestsTableViewProps {
|
|||||||
kind: 'approvals' | 'own';
|
kind: 'approvals' | 'own';
|
||||||
/** Клик по строке — открыть заявку (в выезжающей панели). */
|
/** Клик по строке — открыть заявку (в выезжающей панели). */
|
||||||
onOpen: (request: IMyRequest) => void;
|
onOpen: (request: IMyRequest) => void;
|
||||||
|
/** Точное число «ждут решения СЕЙЧАС» (kind='approvals', без поиска) — для
|
||||||
|
* счётчика в SideNav, чтобы не гонять для него отдельный параллельный
|
||||||
|
* запрос, который мог бы разойтись по времени с тем, что видно в таблице. */
|
||||||
|
onMyTurnCountChange?: (count: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
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
|
// строка, а не только название), поэтому строка — <tr> с onClick + tabIndex
|
||||||
// (доступ с клавиатуры: Enter/Space).
|
// (доступ с клавиатуры: Enter/Space).
|
||||||
@@ -191,7 +132,12 @@ const Table: React.FC<{
|
|||||||
// Оба варианта перечитываются по событию rp-requests-changed
|
// Оба варианта перечитываются по событию rp-requests-changed
|
||||||
// (shared/requestEvents) — после создания заявки и после решения согласующего.
|
// (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 [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
|
||||||
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
|
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
|
||||||
const [totalRows, setTotalRows] = React.useState<number>(0);
|
const [totalRows, setTotalRows] = React.useState<number>(0);
|
||||||
@@ -240,6 +186,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({ context,
|
|||||||
setRows(r.rows);
|
setRows(r.rows);
|
||||||
setTotalRows(r.totalRows);
|
setTotalRows(r.totalRows);
|
||||||
setError('');
|
setError('');
|
||||||
|
if (onMyTurnCountChange) onMyTurnCountChange(r.totalRows);
|
||||||
return;
|
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);
|
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(
|
function toMyRequest(
|
||||||
r: Record<string, unknown>,
|
r: Record<string, unknown>,
|
||||||
author: { EMail?: string; Title?: string } | undefined,
|
author: { EMail?: string; Title?: string } | undefined,
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import * as React from 'react';
|
|||||||
// Маршрутизация портала по параметрам URL.
|
// Маршрутизация портала по параметрам URL.
|
||||||
//
|
//
|
||||||
// Раздел бокового меню — rpnav: 'new' (подать заявку, по умолчанию),
|
// Раздел бокового меню — rpnav: 'new' (подать заявку, по умолчанию),
|
||||||
// 'approvals' (ждут моего решения), 'own' (мои заявки).
|
// 'approvals' (ждут моего решения), 'own' (мои заявки), 'search' (поиск
|
||||||
|
// сразу по обеим категориям — и по своим заявкам, и по тем, где участвовал).
|
||||||
// Открытая заявка — переход по ссылке из потока Power Automate или клик по
|
// Открытая заявка — переход по ссылке из потока Power Automate или клик по
|
||||||
// строке таблицы:
|
// строке таблицы:
|
||||||
// …/СтраницаПортала.aspx?rpmode=approve&rptype=<форма>&rpid=<RowID>
|
// …/СтраницаПортала.aspx?rpmode=approve&rptype=<форма>&rpid=<RowID>
|
||||||
@@ -22,7 +23,7 @@ import * as React from 'react';
|
|||||||
export type PortalMode = 'submit' | 'approve' | 'create';
|
export type PortalMode = 'submit' | 'approve' | 'create';
|
||||||
|
|
||||||
/** Раздел бокового меню. */
|
/** Раздел бокового меню. */
|
||||||
export type NavSection = 'new' | 'approvals' | 'own';
|
export type NavSection = 'new' | 'approvals' | 'own' | 'search';
|
||||||
|
|
||||||
export interface IRoute {
|
export interface IRoute {
|
||||||
mode: PortalMode;
|
mode: PortalMode;
|
||||||
@@ -39,7 +40,7 @@ export interface IRoute {
|
|||||||
const ROUTE_CHANGE_EVENT = 'rp-route-change';
|
const ROUTE_CHANGE_EVENT = 'rp-route-change';
|
||||||
|
|
||||||
function parseNav(raw: string | undefined): NavSection {
|
function parseNav(raw: string | undefined): NavSection {
|
||||||
if (raw === 'approvals' || raw === 'own') return raw;
|
if (raw === 'approvals' || raw === 'own' || raw === 'search') return raw;
|
||||||
return 'new';
|
return 'new';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user