Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/5
This commit was merged in pull request #5.
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
// Подвкладки «Моих заявок». Оформление намеренно повторяет CategoryTabs
|
||||
// (вкладки категорий на подаче заявки) — это тот же по смыслу элемент
|
||||
// «переключатель раздела», и разнобой в двух местах портала выглядел бы ошибкой.
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
gap: $sp-2;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
margin-bottom: $sp-4;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: $gray-500;
|
||||
font-family: $font-sans;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: .2px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color .18s, border-color .18s;
|
||||
|
||||
&:hover {
|
||||
color: $navy-800;
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
color: $navy-900;
|
||||
font-weight: 600;
|
||||
border-bottom-color: $gold-500;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react';
|
||||
import styles from './OwnTabs.module.scss';
|
||||
import { OwnTab } from '../../shared/routing';
|
||||
|
||||
interface IOwnTabItem {
|
||||
id: OwnTab;
|
||||
title: string;
|
||||
}
|
||||
|
||||
// Суффикс «мной» намеренно опущен: раздел уже называется «Мои заявки», и внутри
|
||||
// него он повторялся бы в каждой вкладке. Первая вкладка — про авторство,
|
||||
// остальные три — про решения пользователя в ЧУЖИХ маршрутах.
|
||||
const ITEMS: IOwnTabItem[] = [
|
||||
{ id: 'created', title: 'Инициированные' },
|
||||
{ id: 'approved', title: 'Согласованные' },
|
||||
{ id: 'rejected', title: 'Отклонённые' },
|
||||
{ id: 'executed', title: 'Выполненные' },
|
||||
];
|
||||
|
||||
interface IOwnTabsProps {
|
||||
activeId: OwnTab;
|
||||
onChange: (id: OwnTab) => void;
|
||||
}
|
||||
|
||||
/** Горизонтальный переключатель подвкладок раздела «Мои заявки». */
|
||||
export const OwnTabs: React.FC<IOwnTabsProps> = ({ activeId, onChange }) => (
|
||||
<nav className={styles.tabs} aria-label="Разделы моих заявок">
|
||||
{ITEMS.map((item) => {
|
||||
const isActive = item.id === activeId;
|
||||
const tabClasses = [styles.tab, isActive ? styles.active : ''].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={tabClasses}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
onClick={() => onChange(item.id)}
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
@@ -34,6 +34,49 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// Фильтр по статусу — только на вкладке «Инициированные». Стоит слева от поиска
|
||||
// и прижат к нему: вместе это один блок «чем сузить список».
|
||||
.statusFilter {
|
||||
// appearance: none + своя SVG-галочка — как в shared/ui/Select. Нативная
|
||||
// стрелка ОС рисуется своим шрифтом и рядом с остальными полями портала
|
||||
// выглядит чужеродно (и по-разному в разных браузерах).
|
||||
appearance: none;
|
||||
margin-left: auto;
|
||||
// Справа место под галочку; по вертикали — как у .search, чтобы оба контрола
|
||||
// в шапке были одной высоты.
|
||||
padding: 9px 34px 9px 13px;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $r-sm;
|
||||
background-color: $white;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%236B7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-sm;
|
||||
color: $gray-900;
|
||||
cursor: pointer;
|
||||
transition: .18s;
|
||||
|
||||
@include input-focus-gold;
|
||||
}
|
||||
|
||||
// Когда фильтр есть, отступ до края уже задан им (margin-left: auto) — иначе
|
||||
// поиск сам отжимается вправо.
|
||||
.statusFilter + .search {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
// Цвет пунктов списка — те же цвета, что у StatusBadge (см. statusOptionClassName
|
||||
// в RequestsTableView.tsx). Только color: background-color нативный <option>
|
||||
// показывает не во всех браузерах одинаково, поэтому не используем.
|
||||
.optionDraft { color: $gray-700; }
|
||||
.optionOnApproval { color: $blue-600; }
|
||||
.optionApproved { color: $green-600; }
|
||||
.optionRejected { color: $red-500; }
|
||||
.optionReturned { color: $amber-700; }
|
||||
.optionExecution { color: $navy-800; }
|
||||
.optionClosed { color: $gray-500; }
|
||||
|
||||
.search {
|
||||
width: 280px;
|
||||
max-width: 100%;
|
||||
@@ -155,41 +198,19 @@
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.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;
|
||||
// Причина возврата — свободный текст согласующего, поэтому переносим по словам
|
||||
// (в т.ч. длинное слово без пробелов, иначе оно растянет таблицу).
|
||||
.colComment {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.statusOnApproval {
|
||||
background: $gold-100;
|
||||
color: $gold-600;
|
||||
.commentEmpty {
|
||||
color: $gray-400;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
// Раскраска статуса — теперь только в shared/approval/ui/StatusBadge (см.
|
||||
// использование ниже с size="sm"). Раньше здесь была своя копия этих цветов —
|
||||
// она разошлась с эталоном (На согласовании/Возвращена на доработку оказались
|
||||
// перепутаны), поэтому копию убрали, а не подправили.
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import * as React from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import {
|
||||
ApprovalQueueKind,
|
||||
filterPendingByQuery,
|
||||
getApprovalQueue,
|
||||
getApprovalQueueIndex,
|
||||
getLinkedTypes,
|
||||
getOwnRequests,
|
||||
getPendingApproval,
|
||||
getPendingApprovalIndex,
|
||||
getOwnRequestsByStatus,
|
||||
hydrateDecisionComments,
|
||||
hydratePendingAuthors,
|
||||
searchOwnRequests,
|
||||
} from './myRequests.service';
|
||||
import { IMyRequest } from './models/IMyRequest';
|
||||
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
|
||||
import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType, StatusBadge } from '../../shared/approval';
|
||||
import { onRequestsChanged } from '../../shared/requestEvents';
|
||||
import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery';
|
||||
import { useRoutedQuery } from '../../shared/usePersistedQuery';
|
||||
import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||||
import { Pager } from './Pager';
|
||||
import styles from './RequestsTableView.module.scss';
|
||||
@@ -26,16 +29,157 @@ const PAGE_SIZE = 20;
|
||||
const AUTO_REFRESH_MS = 10 * 60 * 1000;
|
||||
|
||||
|
||||
/**
|
||||
* Что именно показывает таблица. Один компонент обслуживает все списки заявок
|
||||
* портала, а различаются они только источником строк и набором колонок:
|
||||
*
|
||||
* 'approvals' | 'execution' — очереди действий (из строк маршрута);
|
||||
* 'rework' — свои заявки, возвращённые на доработку;
|
||||
* 'own:created' — свои заявки (+ фильтр по статусу);
|
||||
* 'own:approved'|'own:rejected'|'own:executed' — история своих решений.
|
||||
*/
|
||||
export type ListView =
|
||||
| 'approvals'
|
||||
| 'execution'
|
||||
| 'rework'
|
||||
| 'own:created'
|
||||
| 'own:approved'
|
||||
| 'own:rejected'
|
||||
| 'own:executed';
|
||||
|
||||
// Представления, которые строятся на строках маршрута пользователя, и их очередь
|
||||
// в сервисе. Всё, чего здесь нет ('rework'/'own:created'), читается из списка
|
||||
// данных по автору.
|
||||
const QUEUE_BY_VIEW: { [key: string]: ApprovalQueueKind } = {
|
||||
approvals: 'approvals',
|
||||
execution: 'execution',
|
||||
'own:approved': 'approved',
|
||||
'own:rejected': 'rejected',
|
||||
'own:executed': 'executed',
|
||||
};
|
||||
|
||||
const HEADINGS: Record<ListView, string> = {
|
||||
approvals: 'Ждут вашего согласования',
|
||||
execution: 'Ждут вашего исполнения',
|
||||
rework: 'Возвращены вам на доработку',
|
||||
'own:created': 'Инициированные мной',
|
||||
'own:approved': 'Согласованные мной',
|
||||
'own:rejected': 'Отклонённые мной',
|
||||
'own:executed': 'Выполненные мной',
|
||||
};
|
||||
|
||||
const EMPTY_TEXTS: Record<ListView, string> = {
|
||||
approvals: 'Нет заявок, ждущих вашего согласования.',
|
||||
execution: 'Нет заявок, ждущих вашего исполнения.',
|
||||
rework: 'Нет заявок, возвращённых вам на доработку.',
|
||||
'own:created': 'Вы ещё не создавали заявок.',
|
||||
'own:approved': 'Вы ещё ничего не согласовали.',
|
||||
'own:rejected': 'Вы ещё ничего не отклоняли.',
|
||||
'own:executed': 'Вы ещё ничего не выполнили.',
|
||||
};
|
||||
|
||||
// Цвет пункта в выпадающем списке фильтра — то же сопоставление статус→цвет,
|
||||
// что в StatusBadge (VARIANT), только цвет текста, а не бейдж целиком: внутри
|
||||
// нативного <option> нельзя отрендерить компонент, а background-color на нём
|
||||
// браузеры показывают ненадёжно (в отличие от color). Если сопоставление там
|
||||
// поменяется — поправить и здесь, второй раз найти дублирование негде.
|
||||
function statusOptionClassName(status: string): string {
|
||||
switch (status) {
|
||||
case REQUEST_STATUS.onApproval:
|
||||
case REQUEST_STATUS.onExecution:
|
||||
return styles.optionOnApproval;
|
||||
case REQUEST_STATUS.approved:
|
||||
case REQUEST_STATUS.executed:
|
||||
return styles.optionApproved;
|
||||
case REQUEST_STATUS.rejected:
|
||||
return styles.optionRejected;
|
||||
case REQUEST_STATUS.returned:
|
||||
return styles.optionReturned;
|
||||
case REQUEST_STATUS.execution:
|
||||
return styles.optionExecution;
|
||||
case REQUEST_STATUS.closed:
|
||||
return styles.optionClosed;
|
||||
default:
|
||||
return styles.optionDraft;
|
||||
}
|
||||
}
|
||||
|
||||
// Колонки: «Автор» нужен там, где заявка чужая; «Статус» — где он не следует из
|
||||
// самого раздела. В истории решений важно и то, и другое: заявка чужая, а её
|
||||
// текущий статус мог уйти дальше вашего решения.
|
||||
//
|
||||
// 'rework' — свои заявки (автор не нужен) И все с одним статусом «Возвращена на
|
||||
// доработку», раз именно по нему раздел и отобран (см. getOwnRequestsByStatus):
|
||||
// колонка из одинаковых плашек ничего не сообщает, только шумит. Та же логика,
|
||||
// что у очередей согласования/исполнения.
|
||||
//
|
||||
// 'own:created' — статус нужен, только пока показаны ВСЕ заявки. Стоит выбрать
|
||||
// его в фильтре, и колонка вырождается в тот же столбец одинаковых плашек,
|
||||
// поэтому она зависит не от одного вида, а ещё и от фильтра.
|
||||
//
|
||||
// Статус решения, ОБЩИЙ для всех строк раздела, — если он есть, показывать его
|
||||
// колонкой незачем (столбец одинаковых плашек), зато полезен комментарий
|
||||
// согласующего: почему вернули или почему отклонили. Именно за этим в такие
|
||||
// списки и заходят.
|
||||
function decisionStatusFor(view: ListView, statusFilter: string): string | undefined {
|
||||
if (view === 'rework') return REQUEST_STATUS.returned;
|
||||
if (view === 'own:rejected') return REQUEST_STATUS.rejected;
|
||||
if (
|
||||
view === 'own:created' &&
|
||||
(statusFilter === REQUEST_STATUS.returned || statusFilter === REQUEST_STATUS.rejected)
|
||||
) {
|
||||
return statusFilter;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface ITableColumns {
|
||||
author: boolean;
|
||||
status: boolean;
|
||||
/** Заголовок колонки с комментарием; undefined — колонки нет. */
|
||||
commentLabel?: string;
|
||||
}
|
||||
|
||||
function columnsFor(view: ListView, statusFilter: string): ITableColumns {
|
||||
const decisionStatus = decisionStatusFor(view, statusFilter);
|
||||
const commentLabel =
|
||||
decisionStatus === REQUEST_STATUS.rejected
|
||||
? 'Причина отклонения'
|
||||
: decisionStatus === REQUEST_STATUS.returned
|
||||
? 'Причина возврата'
|
||||
: undefined;
|
||||
|
||||
if (view === 'approvals' || view === 'execution') return { author: true, status: false };
|
||||
if (view === 'rework') return { author: false, status: false, commentLabel };
|
||||
if (view === 'own:created') return { author: false, status: !statusFilter, commentLabel };
|
||||
// История решений: заявка чужая, поэтому нужен автор. Статус — только если он
|
||||
// не предопределён самой вкладкой («Отклонённые мной» — это всегда «Отклонено»,
|
||||
// а вот у согласованных/выполненных заявка могла уйти дальше по маршруту).
|
||||
return { author: true, status: !decisionStatus, commentLabel };
|
||||
}
|
||||
|
||||
export interface IRequestsTableViewProps {
|
||||
context: WebPartContext;
|
||||
/** 'approvals' — ждут решения текущего пользователя; 'own' — созданные им. */
|
||||
kind: 'approvals' | 'own';
|
||||
view: ListView;
|
||||
/** Фильтр по статусу заявки; только для view='own:created'. '' — все статусы. */
|
||||
statusFilter?: string;
|
||||
onStatusFilterChange?: (status: string) => void;
|
||||
/**
|
||||
* Текущая страница (1-based) и её смена. Страница живёт в маршруте, а не
|
||||
* внутри компонента: так она попадает в адрес (ссылку можно передать),
|
||||
* переживает открытие/закрытие заявки и запоминается для этой таблицы
|
||||
* (см. persistPage в shared/routing.ts).
|
||||
*/
|
||||
page: number;
|
||||
onPageChange: (page: number) => void;
|
||||
/**
|
||||
* Текст поиска и его смена. Тоже живёт в маршруте (см. page выше): попадает
|
||||
* в адрес, переживает открытие заявки и запоминается для этой таблицы.
|
||||
*/
|
||||
query: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
/** Клик по строке — открыть заявку (в выезжающей панели). */
|
||||
onOpen: (request: IMyRequest) => void;
|
||||
/** Точное число «ждут решения СЕЙЧАС» (kind='approvals', без поиска) — для
|
||||
* счётчика в SideNav, чтобы не гонять для него отдельный параллельный
|
||||
* запрос, который мог бы разойтись по времени с тем, что видно в таблице. */
|
||||
onMyTurnCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
@@ -46,35 +190,19 @@ function formatDate(iso: string): string {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Таблица заявок. Клик в ЛЮБОМ месте строки открывает заявку (кликабельна вся
|
||||
// строка, а не только название), поэтому строка — <tr> с onClick + tabIndex
|
||||
// (доступ с клавиатуры: Enter/Space).
|
||||
//
|
||||
// «approvals»: автор — не текущий пользователь, поэтому нужна колонка «Автор»;
|
||||
// статус не показываем — здесь все заявки по определению на согласовании.
|
||||
// «own»: автор всегда текущий пользователь, зато важен статус.
|
||||
// Набор колонок зависит от раздела и от фильтра — см. columnsFor.
|
||||
const Table: React.FC<{
|
||||
rows: IMyRequest[];
|
||||
kind: 'approvals' | 'own';
|
||||
view: ListView;
|
||||
statusFilter: string;
|
||||
onOpen: (request: IMyRequest) => void;
|
||||
}> = ({ rows, kind, onOpen }) => (
|
||||
}> = ({ rows, view, statusFilter, onOpen }) => {
|
||||
const columns = columnsFor(view, statusFilter);
|
||||
return (
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
@@ -83,8 +211,9 @@ const Table: React.FC<{
|
||||
<th className={styles.colType}>Тип</th>
|
||||
<th className={styles.colTitle}>Название</th>
|
||||
<th className={styles.colDate}>Дата</th>
|
||||
{kind === 'approvals' && <th className={styles.colAuthor}>Автор</th>}
|
||||
{kind === 'own' && <th className={styles.colStatus}>Статус</th>}
|
||||
{columns.author && <th className={styles.colAuthor}>Автор</th>}
|
||||
{columns.status && <th className={styles.colStatus}>Статус</th>}
|
||||
{columns.commentLabel && <th className={styles.colComment}>{columns.commentLabel}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -107,14 +236,19 @@ const Table: React.FC<{
|
||||
<td className={styles.colType}>{r.requestTypeName}</td>
|
||||
<td className={styles.colTitle}>{r.title || `#${r.id}`}</td>
|
||||
<td className={styles.colDate}>{formatDate(r.created)}</td>
|
||||
{kind === 'approvals' && (
|
||||
{columns.author && (
|
||||
<td className={styles.colAuthor}>
|
||||
{[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'}
|
||||
</td>
|
||||
)}
|
||||
{kind === 'own' && (
|
||||
{columns.status && (
|
||||
<td className={styles.colStatus}>
|
||||
<span className={`${styles.status} ${statusClass(r.status)}`}>{r.status || '—'}</span>
|
||||
{r.status ? <StatusBadge status={r.status} size="sm" /> : '—'}
|
||||
</td>
|
||||
)}
|
||||
{columns.commentLabel && (
|
||||
<td className={styles.colComment}>
|
||||
{r.decisionComment || <span className={styles.commentEmpty}>Без комментария</span>}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
@@ -122,7 +256,8 @@ const Table: React.FC<{
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
// Раздел с таблицей заявок: поиск по всем полям + вывод постранично.
|
||||
//
|
||||
@@ -133,40 +268,62 @@ const Table: React.FC<{
|
||||
// результат мог быть неустойчивым (заявка то находилась, то нет, в зависимости
|
||||
// от того, попала ли она в окно подстраховки); эту гибридную логику убрали.
|
||||
//
|
||||
// 'own' без поиска — getOwnRequests (REST по AuthorId, страница режется в
|
||||
// памяти). 'own' с поиском — searchOwnRequests (Search, RowLimit/StartRow).
|
||||
// 'own:created' без фильтра и поиска — getOwnRequests (Search + REST-довесок на
|
||||
// свежесть). С поиском — searchOwnRequests (Search, RowLimit/StartRow).
|
||||
//
|
||||
// 'approvals' без поиска — getPendingApproval, REST по ApproverId(+Status),
|
||||
// страница нарезается на СЕРВЕРЕ (сам берёт нужный срез индекса и дочитывает
|
||||
// автора только для него).
|
||||
// 'approvals' с поиском — область расширяется до «где пользователь вообще есть
|
||||
// в маршруте» (getPendingApprovalIndex, ПОЛНЫЙ список без автора — постранично
|
||||
// его тут не взять, т.к. фильтрация текстом идёт уже после), затем текстовый
|
||||
// фильтр через Search (filterPendingByQuery), и только после фильтрации —
|
||||
// нарезка на страницы и дочитывание автора (hydratePendingAuthors) — тоже
|
||||
// только для видимой страницы, а не для всего отфильтрованного набора.
|
||||
// Очереди и история решений ('approvals'/'execution'/'own:*' кроме created) без
|
||||
// поиска — getApprovalQueue: REST по ApproverId(+Status), страница нарезается на
|
||||
// СЕРВЕРЕ (сам берёт нужный срез индекса и дочитывает автора только для него).
|
||||
// С поиском — getApprovalQueueIndex даёт ПОЛНЫЙ список этой же очереди без
|
||||
// автора (постранично его тут не взять, т.к. фильтрация текстом идёт уже
|
||||
// после), затем текстовый фильтр через Search (filterPendingByQuery), и только
|
||||
// после фильтрации — нарезка на страницы и дочитывание автора
|
||||
// (hydratePendingAuthors) — тоже только для видимой страницы.
|
||||
//
|
||||
// Оба варианта перечитываются по событию rp-requests-changed
|
||||
// Отбор по статусу ЗАЯВКИ ('rework' и фильтр на 'own:created') — НЕ через KQL:
|
||||
// managed property для произвольной Choice-колонки ненадёжна (см.
|
||||
// getOwnRequestsByStatus в myRequests.service.ts — там объяснение и ссылка на
|
||||
// реальный инцидент). Вместо этого Search ищет только по автору (надёжная
|
||||
// AuthorOWSUSER), а статус фильтруется в памяти по полям, дочитанным через
|
||||
// REST; постраничность там же, в памяти.
|
||||
//
|
||||
// Все варианты перечитываются по событию rp-requests-changed
|
||||
// (shared/requestEvents) — после создания заявки и после решения согласующего.
|
||||
// Поиск идёт по индексу, поэтому свежая правка может проявиться не мгновенно.
|
||||
export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
context,
|
||||
kind,
|
||||
view,
|
||||
statusFilter = '',
|
||||
onStatusFilterChange,
|
||||
page,
|
||||
onPageChange,
|
||||
query,
|
||||
onQueryChange,
|
||||
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);
|
||||
const [page, setPage] = React.useState<number>(1);
|
||||
const [query, setQuery] = usePersistedQuery(kind);
|
||||
const [error, setError] = React.useState<string>('');
|
||||
// query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что
|
||||
// уходит в запрос (после паузы в наборе). Всё ниже завязано на debouncedQuery,
|
||||
// иначе поиск бил бы запросом по каждой набранной букве.
|
||||
const debouncedQuery = useDebouncedQuery(query);
|
||||
|
||||
React.useEffect(() => setPage(1), [debouncedQuery, kind]);
|
||||
// input — то, что в поле ввода (меняется сразу); в маршрут значение уходит
|
||||
// после паузы в наборе. Данные грузятся по МАРШРУТНОМУ query — он и так уже
|
||||
// «отложенный», отдельный debounce здесь был бы вторым по счёту.
|
||||
const [input, setInput] = useRoutedQuery(query, onQueryChange);
|
||||
const debouncedQuery = query;
|
||||
|
||||
// Какой ИМЕННО таблице принадлежат лежащие в состоянии rows/totalRows.
|
||||
// Компонент один на все разделы (см. LIST_VIEWS в RequestPortal), поэтому при
|
||||
// переключении раздела его состояние ещё хранит данные ПРЕДЫДУЩЕГО, пока не
|
||||
// отработает загрузка. Без этой отметки сброс страницы ниже принимал решение
|
||||
// по чужому totalRows и сбивал страницу на первую — из-за чего запомненная
|
||||
// страница затиралась при каждом переходе по левому меню.
|
||||
// Страница в ключ НЕ входит: totalRows один на всю таблицу, а не на страницу.
|
||||
const dataKey = `${view}|${statusFilter}|${debouncedQuery.trim()}`;
|
||||
const [loadedKey, setLoadedKey] = React.useState<string>('');
|
||||
|
||||
// Страницу при смене запроса здесь НЕ сбрасываем: и запрос, и страница живут
|
||||
// в маршруте, и меняются они одним переходом (см. setQuery в RequestPortal).
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -185,7 +342,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
React.useEffect(() => {
|
||||
if (types === undefined) return;
|
||||
let cancelled = false;
|
||||
const cacheKey = pageKey('table', [kind, debouncedQuery.trim(), page]);
|
||||
const cacheKey = pageKey('table', [view, statusFilter, debouncedQuery.trim(), page]);
|
||||
|
||||
// Сразу показываем то, что уже загружали для ЭТОЙ вкладки (и этого запроса
|
||||
// и страницы), а обновление идёт фоном. Компонент один на обе вкладки —
|
||||
@@ -195,30 +352,56 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
const cached = readPage(cacheKey);
|
||||
setRows(cached ? cached.rows : undefined);
|
||||
setTotalRows(cached ? cached.totalRows : 0);
|
||||
// Пока не подтвердим, что загруженное относится к ТЕКУЩЕЙ таблице, ничего
|
||||
// на основании totalRows решать нельзя — см. loadedKey ниже.
|
||||
setLoadedKey(cached ? dataKey : '');
|
||||
|
||||
const fetchPage = async (): Promise<IPage> => {
|
||||
if (kind === 'own') {
|
||||
return debouncedQuery.trim().length > 0
|
||||
const q = debouncedQuery.trim();
|
||||
|
||||
const fetchRows = async (): Promise<IPage> => {
|
||||
// «Доработка» — тот же список, что «Инициированные», просто со статусом,
|
||||
// зашитым в раздел, а не выбранным в фильтре.
|
||||
if (view === 'rework' || (view === 'own:created' && statusFilter)) {
|
||||
const status = view === 'rework' ? REQUEST_STATUS.returned : statusFilter;
|
||||
return getOwnRequestsByStatus(context, types, status, page, debouncedQuery);
|
||||
}
|
||||
|
||||
if (view === 'own:created') {
|
||||
return q.length > 0
|
||||
? searchOwnRequests(context, types, debouncedQuery, page)
|
||||
: getOwnRequests(context, types, page);
|
||||
}
|
||||
|
||||
if (debouncedQuery.trim().length === 0) {
|
||||
const queue = QUEUE_BY_VIEW[view];
|
||||
if (q.length === 0) {
|
||||
// Без поиска — страница уже нарезана и дочитана сервером.
|
||||
const r = await getPendingApproval(context, 'myTurn', page);
|
||||
const r = await getApprovalQueue(context, queue, page);
|
||||
return { rows: r.rows, totalRows: r.totalRows };
|
||||
}
|
||||
|
||||
// С поиском — сначала полный список «где я есть в маршруте» (без
|
||||
// автора), фильтруем текстом, и только потом режем на страницы и
|
||||
// дочитываем автора — только для видимой страницы.
|
||||
const idx = await getPendingApprovalIndex(context, 'involved');
|
||||
// С поиском — сначала полный список этой очереди (без автора), фильтруем
|
||||
// текстом, и только потом режем на страницы и дочитываем автора — только
|
||||
// для видимой страницы.
|
||||
const idx = await getApprovalQueueIndex(context, queue);
|
||||
const filtered = await filterPendingByQuery(context, idx.types, idx.rows, debouncedQuery);
|
||||
const pageRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
await hydratePendingAuthors(context, idx.types, pageRows);
|
||||
return { rows: pageRows, totalRows: filtered.length };
|
||||
};
|
||||
|
||||
// Комментарий к решению дочитываем ОДНИМ местом на все ветки: «Доработка» и
|
||||
// «Отклонённые мной» приходят разными путями (по автору и по строкам
|
||||
// маршрута соответственно), а колонка у них одинаковая по смыслу. И только
|
||||
// для видимой страницы — как и автор в hydratePendingAuthors.
|
||||
const fetchPage = async (): Promise<IPage> => {
|
||||
const result = await fetchRows();
|
||||
const decisionStatus = decisionStatusFor(view, statusFilter);
|
||||
if (decisionStatus) {
|
||||
await hydrateDecisionComments(context, types, result.rows, decisionStatus);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
const fresh = await fetchPage();
|
||||
@@ -233,10 +416,10 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
setRows(fresh.rows);
|
||||
setTotalRows(fresh.totalRows);
|
||||
}
|
||||
// Данные точно от текущей таблицы — теперь по ним можно судить о
|
||||
// количестве страниц (см. сброс страницы ниже).
|
||||
setLoadedKey(dataKey);
|
||||
setError('');
|
||||
if (onMyTurnCountChange && kind === 'approvals' && debouncedQuery.trim().length === 0) {
|
||||
onMyTurnCountChange(fresh.totalRows);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) setError((e as Error).message);
|
||||
}
|
||||
@@ -256,29 +439,58 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
unsubscribe();
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [context, types, kind, debouncedQuery, page]);
|
||||
}, [context, types, view, statusFilter, debouncedQuery, page]);
|
||||
|
||||
// Обе ветки теперь отдают уже нарезанную страницу — просто показываем как есть.
|
||||
// Все ветки отдают уже нарезанную страницу — просто показываем как есть.
|
||||
const q = debouncedQuery.trim();
|
||||
const total = totalRows;
|
||||
const visible = rows || [];
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
const heading = kind === 'approvals' ? 'Ждут вашего решения' : 'Мои заявки';
|
||||
// Страница может оказаться за пределами набора: пришла из старой ссылки или
|
||||
// из запомненной, а заявок с тех пор стало меньше (часть согласовали, и они
|
||||
// ушли из этой очереди). Показывать пустую таблицу с непустым счётчиком —
|
||||
// выглядит как поломка, поэтому возвращаемся на последнюю существующую.
|
||||
//
|
||||
// Строго ПОСЛЕ проверки loadedKey: totalRows в состоянии может быть ещё от
|
||||
// предыдущей таблицы (компонент общий), и решение по нему сбило бы страницу
|
||||
// на первую при обычном переходе по левому меню.
|
||||
React.useEffect(() => {
|
||||
if (loadedKey !== dataKey) return;
|
||||
if (rows && page > totalPages) onPageChange(totalPages);
|
||||
}, [loadedKey, dataKey, rows, page, totalPages, onPageChange]);
|
||||
|
||||
return (
|
||||
<section className={styles.view}>
|
||||
<div className={styles.head}>
|
||||
<h2 className={styles.heading}>
|
||||
{heading}
|
||||
{HEADINGS[view]}
|
||||
{rows && <span className={styles.count}>{total}</span>}
|
||||
</h2>
|
||||
{view === 'own:created' && onStatusFilterChange && (
|
||||
<select
|
||||
// Цвет самого <select> (закрытое состояние) — тем же классом, что и
|
||||
// у выбранного <option>: statusFilter задаёт нейтральные border/фон,
|
||||
// а конкретный statusOptionClassName(statusFilter) перекрывает color.
|
||||
className={`${styles.statusFilter} ${statusFilter ? statusOptionClassName(statusFilter) : ''}`}
|
||||
value={statusFilter}
|
||||
onChange={(e) => onStatusFilterChange(e.target.value)}
|
||||
aria-label="Фильтр по статусу"
|
||||
>
|
||||
<option value="">Все статусы</option>
|
||||
{REQUEST_STATUS_CHOICES.map((s) => (
|
||||
<option key={s} value={s} className={statusOptionClassName(s)}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<input
|
||||
type="search"
|
||||
className={styles.search}
|
||||
placeholder="Поиск по заявкам…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -286,17 +498,13 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
{!error && rows === undefined && <div className={styles.empty}>Загрузка…</div>}
|
||||
{!error && rows && total === 0 && (
|
||||
<div className={styles.empty}>
|
||||
{q
|
||||
? `По запросу «${debouncedQuery}» ничего не найдено.`
|
||||
: kind === 'approvals'
|
||||
? 'Нет заявок, ждущих вашего решения.'
|
||||
: 'Вы ещё не создавали заявок.'}
|
||||
{q ? `По запросу «${debouncedQuery}» ничего не найдено.` : EMPTY_TEXTS[view]}
|
||||
</div>
|
||||
)}
|
||||
{!error && rows && total > 0 && (
|
||||
<>
|
||||
<Table rows={visible} kind={kind} onOpen={onOpen} />
|
||||
<Pager page={page} totalPages={totalPages} onChange={setPage} />
|
||||
<Table rows={visible} view={view} statusFilter={statusFilter} onOpen={onOpen} />
|
||||
<Pager page={page} totalPages={totalPages} onChange={onPageChange} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -163,41 +163,7 @@
|
||||
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;
|
||||
}
|
||||
// Раскраска статуса — теперь только в shared/approval/ui/StatusBadge (см.
|
||||
// использование ниже с size="sm"). Раньше здесь была своя копия этих цветов —
|
||||
// она разошлась с эталоном (На согласовании/Возвращена на доработку оказались
|
||||
// перепутаны), поэтому копию убрали, а не подправили.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as React from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { getLinkedTypes, searchMyRequests } from './myRequests.service';
|
||||
import { getLinkedTypes, hydratePendingAuthors, searchMyRequests } from './myRequests.service';
|
||||
import { IMyRequest } from './models/IMyRequest';
|
||||
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
|
||||
import { IRequestType, StatusBadge } from '../../shared/approval';
|
||||
import { onRequestsChanged } from '../../shared/requestEvents';
|
||||
import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery';
|
||||
import { useRoutedQuery } from '../../shared/usePersistedQuery';
|
||||
import { pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||||
import { Pager } from './Pager';
|
||||
import styles from './SearchView.module.scss';
|
||||
@@ -20,10 +20,35 @@ const AUTO_REFRESH_MS = 10 * 60 * 1000;
|
||||
|
||||
export interface ISearchViewProps {
|
||||
context: WebPartContext;
|
||||
/**
|
||||
* Текущая страница (1-based) и её смена — как и в RequestsTableView, страница
|
||||
* живёт в маршруте, а не внутри компонента (см. shared/routing.ts).
|
||||
*/
|
||||
page: number;
|
||||
onPageChange: (page: number) => void;
|
||||
/** Текст поиска и его смена — тоже из маршрута (см. page выше). */
|
||||
query: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
/** Клик по строке — открыть заявку (в выезжающей панели). */
|
||||
onOpen: (request: IMyRequest) => void;
|
||||
}
|
||||
|
||||
const rowKey = (r: IMyRequest): string => `${r.requestTypeId}-${r.id}`;
|
||||
|
||||
// Автор, уже дочитанный ранее (см. authors в SearchView), — проставляем его в
|
||||
// свежий результат поиска до сравнения с кешем. Иначе фоновое обновление
|
||||
// приносило бы строки без автора, слепок страницы отличался бы от кешированного
|
||||
// на ровном месте, таблица моргала бы и автор загружался заново.
|
||||
function applyKnownAuthors(rows: IMyRequest[], known: Map<string, { name: string; email: string }>): void {
|
||||
rows.forEach((r) => {
|
||||
const a = known.get(rowKey(r));
|
||||
if (a) {
|
||||
r.authorName = a.name;
|
||||
r.authorEmail = a.email;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
@@ -32,23 +57,6 @@ function formatDate(iso: string): string {
|
||||
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 (где по смыслу раздела нужно либо Автор,
|
||||
// либо Статус) — здесь смешаны «свои» и «где участвовал» заявки, поэтому
|
||||
// показываем обе колонки сразу, плюс небольшая метка «Автор/Согласование» —
|
||||
@@ -91,7 +99,7 @@ const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => voi
|
||||
{[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'}
|
||||
</td>
|
||||
<td className={styles.colStatus}>
|
||||
<span className={`${styles.status} ${statusClass(r.status)}`}>{r.status || '—'}</span>
|
||||
{r.status ? <StatusBadge status={r.status} size="sm" /> : '—'}
|
||||
</td>
|
||||
<td className={styles.colKind}>{r.kind === 'own' ? 'Создал(а) я' : 'Участвую'}</td>
|
||||
</tr>
|
||||
@@ -111,20 +119,33 @@ const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => voi
|
||||
// после решения согласующего) — но т.к. это по большей части Search, свежая
|
||||
// правка может проявиться не мгновенно (частично компенсируется довеском
|
||||
// последних 10 минут через REST — см. myRequests.service.ts).
|
||||
export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
|
||||
export const SearchView: React.FC<ISearchViewProps> = ({
|
||||
context,
|
||||
page,
|
||||
onPageChange,
|
||||
query,
|
||||
onQueryChange,
|
||||
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] = usePersistedQuery('search');
|
||||
// query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что
|
||||
// уходит в запрос (после паузы в наборе). Здесь это особенно важно: поиск
|
||||
// ходит сразу по двум источникам (свои заявки + где участвую), то есть
|
||||
// каждая набранная буква стоила бы двух серий запросов.
|
||||
const debouncedQuery = useDebouncedQuery(query);
|
||||
const [error, setError] = React.useState<string>('');
|
||||
|
||||
React.useEffect(() => setPage(1), [debouncedQuery]);
|
||||
// Дочитанные авторы, ключ — requestTypeId-id. Живёт между запросами намеренно:
|
||||
// ключ глобально уникален, так что при возврате к прежнему поиску автор уже
|
||||
// известен и запрос не повторяется.
|
||||
const authors = React.useRef<Map<string, { name: string; email: string }>>(new Map());
|
||||
|
||||
// input — то, что в поле ввода (меняется сразу); в маршрут значение уходит
|
||||
// после паузы в наборе. Здесь пауза особенно важна: поиск ходит сразу по двум
|
||||
// источникам (свои заявки + где участвую), то есть каждая набранная буква
|
||||
// стоила бы двух серий запросов.
|
||||
const [input, setInput] = useRoutedQuery(query, onQueryChange);
|
||||
const debouncedQuery = query;
|
||||
|
||||
// Страницу при смене запроса здесь НЕ сбрасываем — это делает setQuery в
|
||||
// RequestPortal одним переходом вместе с самим запросом.
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -140,6 +161,11 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
|
||||
};
|
||||
}, [context]);
|
||||
|
||||
// Поиск зависит ТОЛЬКО от запроса, не от страницы: результат приходит целиком
|
||||
// и кешируется целиком, а страницы нарезаются ниже, в рендере. Раньше страница
|
||||
// входила в ключ кеша — и каждое листание было промахом, который заново гонял
|
||||
// обе серии запросов (свои + где участвую) и показывал «Ищем…», хотя все эти
|
||||
// строки уже были получены при загрузке первой страницы.
|
||||
React.useEffect(() => {
|
||||
if (types === undefined) return;
|
||||
if (debouncedQuery.trim().length === 0) {
|
||||
@@ -148,7 +174,7 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const cacheKey = pageKey('search', [debouncedQuery.trim(), page]);
|
||||
const cacheKey = pageKey('search', [debouncedQuery.trim()]);
|
||||
|
||||
// Уже искали это раньше — показываем прежний результат сразу, обновление
|
||||
// идёт фоном (см. RequestsTableView, там та же схема и те же причины).
|
||||
@@ -158,8 +184,9 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
const fresh = await searchMyRequests(context, types, debouncedQuery, page);
|
||||
const fresh = await searchMyRequests(context, types, debouncedQuery);
|
||||
if (cancelled) return;
|
||||
applyKnownAuthors(fresh.rows, authors.current);
|
||||
const prev = readPage(cacheKey);
|
||||
if (!prev || pageSignature(prev) !== pageSignature(fresh)) {
|
||||
writePage(cacheKey, fresh);
|
||||
@@ -184,10 +211,39 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
|
||||
unsubscribe();
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [context, types, debouncedQuery, page]);
|
||||
}, [context, types, debouncedQuery]);
|
||||
|
||||
const hasQuery = debouncedQuery.trim().length > 0;
|
||||
const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE));
|
||||
const visible = React.useMemo(
|
||||
() => (rows ? rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) : undefined),
|
||||
[rows, page]
|
||||
);
|
||||
|
||||
// Автора у строк 'approval' дочитываем отдельным запросом и только для видимой
|
||||
// страницы: на весь набор (до 300 строк) это был бы $filter из сотен Id —
|
||||
// такой длины URL SharePoint не переваривает. Строки мутируются на месте, то
|
||||
// есть автор оседает и в кеше; карта authors не даёт дёргать один и тот же
|
||||
// запрос повторно (в т.ч. когда автор оказался пустым).
|
||||
React.useEffect(() => {
|
||||
if (types === undefined || visible === undefined) return undefined;
|
||||
const need = visible.filter((r) => r.kind === 'approval' && !authors.current.has(rowKey(r)));
|
||||
if (need.length === 0) return undefined;
|
||||
|
||||
let cancelled = false;
|
||||
hydratePendingAuthors(context, types, need)
|
||||
.then(() => {
|
||||
need.forEach((r) =>
|
||||
authors.current.set(rowKey(r), { name: r.authorName ?? '', email: r.authorEmail ?? '' })
|
||||
);
|
||||
if (!cancelled) setRows((prev) => (prev ? [...prev] : prev));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [context, types, visible]);
|
||||
|
||||
return (
|
||||
<section className={styles.view}>
|
||||
@@ -200,8 +256,8 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
|
||||
type="search"
|
||||
className={styles.search}
|
||||
placeholder="Введите текст — ищем по своим заявкам и по тем, где вы участвуете…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -214,8 +270,8 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
|
||||
)}
|
||||
{!error && hasQuery && rows && totalRows > 0 && (
|
||||
<>
|
||||
<Table rows={rows} onOpen={onOpen} />
|
||||
<Pager page={page} totalPages={totalPages} onChange={setPage} />
|
||||
<Table rows={visible ?? []} onOpen={onOpen} />
|
||||
<Pager page={page} totalPages={totalPages} onChange={onPageChange} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -15,4 +15,18 @@ export interface IMyRequest {
|
||||
formKey: string;
|
||||
/** 'own' — заявка, созданная текущим пользователем; 'approval' — ждёт его решения. */
|
||||
kind: 'own' | 'approval';
|
||||
/**
|
||||
* Статус СВОЕЙ строки маршрута (не заявки) — «На согласовании», «Согласовано»
|
||||
* и т.п. Заполняется только у kind='approval'. Нужен, чтобы разнести заявки
|
||||
* по очередям, не сканируя списки повторно (см. getMyTurnCounts). В таблицах
|
||||
* не показывается — поэтому и не входит в pageSignature.
|
||||
*/
|
||||
myStepStatus?: string;
|
||||
/**
|
||||
* Комментарий согласующего к решению — причина возврата на доработку или
|
||||
* отклонения (какое именно решение, задаёт вызывающая сторона). Живёт не в
|
||||
* списке данных, а в строке МАРШРУТА, поэтому дочитывается отдельно и только
|
||||
* там, где показывается (см. hydrateDecisionComments).
|
||||
*/
|
||||
decisionComment?: string;
|
||||
}
|
||||
|
||||
@@ -38,9 +38,20 @@ const OWN_REST_TOP = 200;
|
||||
// ApproverOWSUSER в схеме поиска не находилось (разбирались 2026-08-06).
|
||||
// Суффикс — тип исходного поля: OWSUSER для Person/Group.
|
||||
const MP_APPROVER = 'TansuSiteApproverOWSUSER';
|
||||
// Статус строки маршрута — Choice-поле, отсюда суффикс OWSCHCS (подтверждено
|
||||
// отладочным перебором 2026-08-06; у Person-полей он OWSUSER, см. MP_APPROVER).
|
||||
const MP_STATUS = 'RequestStatusOWSCHCS';
|
||||
// Статус СТРОКИ МАРШРУТА (колонка ApprovalStatus списка «— согласование») —
|
||||
// Choice-поле, отсюда суффикс OWSCHCS (подтверждено отладочным перебором
|
||||
// 2026-08-06; у Person-полей он OWSUSER, см. MP_APPROVER). Колонку не путать с
|
||||
// RequestStatus списка ДАННЫХ — это статус ЗАЯВКИ целиком.
|
||||
//
|
||||
// Для статуса ЗАЯВКИ (список ДАННЫХ) отдельной managed property здесь
|
||||
// НАМЕРЕННО нет: имя для произвольной Choice-колонки в схеме поиска либо надо
|
||||
// знать точно, либо угадывать по конвенции — угадали неверно (или колонка
|
||||
// после переименования Status → RequestStatus ещё не переиндексирована), и
|
||||
// фильтр по статусу молча возвращал 0 заявок. Поэтому статус заявки
|
||||
// фильтруется НЕ в KQL, а в памяти после дочитывания реальных полей через REST
|
||||
// (см. getOwnRequestsByStatus) — Search там ищет только по надёжному
|
||||
// AuthorOWSUSER, статус ни при чём.
|
||||
const MP_STATUS = 'ApprovalStatusOWSCHCS';
|
||||
|
||||
export function currentUserEmail(context: WebPartContext): string {
|
||||
return (context.pageContext.user.email || '').toLowerCase();
|
||||
@@ -60,16 +71,41 @@ export async function getLinkedTypes(context: WebPartContext): Promise<IRequestT
|
||||
// пользователь участвует или будет участвовать.
|
||||
export type ApprovalScope = 'myTurn' | 'involved';
|
||||
|
||||
// Очереди и истории, построенные на СТРОКАХ МАРШРУТА текущего пользователя.
|
||||
// Отличаются только набором статусов его строки — сам механизм (найти свои
|
||||
// строки → взять их заявки) общий, поэтому статусы вынесены в параметр, а не
|
||||
// зашиты в indexApprovalsForType.
|
||||
//
|
||||
// approvals/execution — очереди действий: ход за пользователем прямо сейчас.
|
||||
// Раньше обе выдавались одним разделом «На согласовании» (фильтр включал
|
||||
// оба статуса сразу); теперь это РАЗНЫЕ разделы и пересечения между ними
|
||||
// нет — заявка ровно в одном из них.
|
||||
// approved/rejected/executed — история уже принятых им решений
|
||||
// (подвкладки «Моих заявок»).
|
||||
export type ApprovalQueueKind = 'approvals' | 'execution' | 'approved' | 'rejected' | 'executed';
|
||||
|
||||
const QUEUE_STATUSES: Record<ApprovalQueueKind, string[]> = {
|
||||
approvals: [REQUEST_STATUS.onApproval],
|
||||
execution: [REQUEST_STATUS.onExecution],
|
||||
approved: [REQUEST_STATUS.approved],
|
||||
rejected: [REQUEST_STATUS.rejected],
|
||||
executed: [REQUEST_STATUS.executed],
|
||||
};
|
||||
|
||||
// Статусы строки, означающие «ход за пользователем» — для счётчиков в меню
|
||||
// (обе очереди считаются за один проход, см. getMyTurnCounts).
|
||||
const MY_TURN_STATUSES: string[] = [REQUEST_STATUS.onApproval, REQUEST_STATUS.onExecution];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// «На согласовании» — раньше сюда приходилось читать ВЕСЬ список согласования
|
||||
// (иначе на клиенте нечем было посчитать «чья очередь»). Теперь очередь
|
||||
// проставляется при создании/решении (см. approvalList.service.ts/createSteps,
|
||||
// approverDecision.service.ts/decide) — «чья сейчас очередь» это просто
|
||||
// значение колонки Status, поэтому здесь достаточно ОДНОГО отфильтрованного
|
||||
// запроса: ApproverId (+ Status для 'myTurn'). Список согласования больше НЕ
|
||||
// сканируется целиком, независимо от его размера.
|
||||
// значение колонки ApprovalStatus, поэтому здесь достаточно ОДНОГО
|
||||
// отфильтрованного запроса: ApproverId (+ ApprovalStatus для 'myTurn').
|
||||
// Список согласования больше НЕ сканируется целиком, независимо от его размера.
|
||||
//
|
||||
// Заявку (Title/Status/Created) читаем сразу через expand по Lookup-полю
|
||||
// Заявку (Title/RequestStatus/Created) читаем сразу через expand по Lookup-полю
|
||||
// Ticket — отдельный запрос к списку данных нужен только за Author
|
||||
// (двухуровневый expand Ticket/Author у SharePoint REST ненадёжен), и то —
|
||||
// ТОЛЬКО для строк текущей страницы (см. hydrateAuthors ниже), а не для всего
|
||||
@@ -93,13 +129,14 @@ const PENDING_PAGE_SIZE = 20;
|
||||
// см. filterPendingByQuery и вызывающую сторону в RequestsTableView).
|
||||
async function buildPendingIndex(
|
||||
context: WebPartContext,
|
||||
scope: ApprovalScope
|
||||
scope: ApprovalScope,
|
||||
statuses: string[]
|
||||
): Promise<{ types: IRequestType[]; index: IMyRequest[]; debug: IMyRequestsTypeDebug[] }> {
|
||||
const types = await getLinkedTypes(context);
|
||||
const tokenEmail = currentUserEmail(context);
|
||||
|
||||
const perType = await Promise.all(
|
||||
types.map((t) => indexApprovalsForType(context, t, tokenEmail, scope))
|
||||
types.map((t) => indexApprovalsForType(context, t, tokenEmail, scope, statuses))
|
||||
);
|
||||
|
||||
// Свежие сверху — типы объединяются в один список, сортировать нужно после слияния.
|
||||
@@ -112,12 +149,12 @@ async function buildPendingIndex(
|
||||
|
||||
// Без текстового поиска: страница нарезается ЗДЕСЬ (сервер решает, какие 20
|
||||
// заявок отдать), автор дочитывается только для них.
|
||||
export async function getPendingApproval(
|
||||
export async function getApprovalQueue(
|
||||
context: WebPartContext,
|
||||
scope: ApprovalScope = 'myTurn',
|
||||
kind: ApprovalQueueKind,
|
||||
page: number = 1
|
||||
): Promise<IPendingApprovalPage> {
|
||||
const { types, index, debug } = await buildPendingIndex(context, scope);
|
||||
const { types, index, debug } = await buildPendingIndex(context, 'myTurn', QUEUE_STATUSES[kind]);
|
||||
|
||||
const pageRows = index.slice((page - 1) * PENDING_PAGE_SIZE, page * PENDING_PAGE_SIZE);
|
||||
await hydrateAuthors(context, types, pageRows);
|
||||
@@ -128,14 +165,59 @@ export async function getPendingApproval(
|
||||
// С текстовым поиском: полный индекс (без автора) — фильтрует его вызывающая
|
||||
// сторона через filterPendingByQuery, а страницу и автора берёт из уже
|
||||
// отфильтрованного результата (см. hydratePendingAuthors).
|
||||
//
|
||||
// Область поиска — та же, что и у самой вкладки (статусы очереди уходят в KQL):
|
||||
// раньше при вводе текста «На согласовании» расширялась до «где я вообще есть в
|
||||
// маршруте», но теперь для сквозного поиска есть отдельный раздел «Поиск», а
|
||||
// внутри вкладки предсказуемее искать по тому же набору, который в ней виден.
|
||||
export async function getApprovalQueueIndex(
|
||||
context: WebPartContext,
|
||||
kind: ApprovalQueueKind
|
||||
): Promise<{ rows: IMyRequest[]; types: IRequestType[]; debug: IMyRequestsTypeDebug[] }> {
|
||||
const { types, index, debug } = await buildPendingIndex(context, 'involved', QUEUE_STATUSES[kind]);
|
||||
return { rows: index, types, debug };
|
||||
}
|
||||
|
||||
// Полный индекс «где я вообще есть в маршруте», без отбора по статусу — нужен
|
||||
// только разделу «Поиск» (см. searchMyRequests).
|
||||
export async function getPendingApprovalIndex(
|
||||
context: WebPartContext,
|
||||
scope: ApprovalScope
|
||||
): Promise<{ rows: IMyRequest[]; types: IRequestType[]; debug: IMyRequestsTypeDebug[] }> {
|
||||
const { types, index, debug } = await buildPendingIndex(context, scope);
|
||||
const { types, index, debug } = await buildPendingIndex(context, scope, []);
|
||||
return { rows: index, types, debug };
|
||||
}
|
||||
|
||||
/** Счётчики очередей для бокового меню. */
|
||||
export interface INavCounts {
|
||||
approvals: number;
|
||||
execution: number;
|
||||
rework: number;
|
||||
}
|
||||
|
||||
// «На согласовании» и «На исполнении» считаются за ОДИН проход: разделять их по
|
||||
// отдельным запросам значило бы дважды просканировать одни и те же списки
|
||||
// согласования. Статус своей строки приходит в myStepStatus (см.
|
||||
// indexApprovalsForType), по нему и разносим.
|
||||
//
|
||||
// «Доработка» живёт в других списках (данные, по автору), поэтому считается
|
||||
// своим запросом — но параллельно, и ровно тем же вызовом, что наполняет саму
|
||||
// вкладку: иначе счётчик и список могли бы разойтись.
|
||||
export async function getNavCounts(context: WebPartContext): Promise<INavCounts> {
|
||||
const types = await getLinkedTypes(context);
|
||||
|
||||
const [turn, rework] = await Promise.all([
|
||||
buildPendingIndex(context, 'myTurn', MY_TURN_STATUSES),
|
||||
getOwnRequestsByStatus(context, types, REQUEST_STATUS.returned, 1),
|
||||
]);
|
||||
|
||||
return {
|
||||
approvals: turn.index.filter((r) => r.myStepStatus === REQUEST_STATUS.onApproval).length,
|
||||
execution: turn.index.filter((r) => r.myStepStatus === REQUEST_STATUS.onExecution).length,
|
||||
rework: rework.totalRows,
|
||||
};
|
||||
}
|
||||
|
||||
// Публичная обёртка над hydrateAuthors — для дочитывания автора по уже
|
||||
// отфильтрованной и нарезанной на страницу выборке (см. getPendingApprovalIndex).
|
||||
export async function hydratePendingAuthors(
|
||||
@@ -147,7 +229,7 @@ export async function hydratePendingAuthors(
|
||||
}
|
||||
|
||||
// Лёгкий индекс совпадающих заявок для одного типа: строки согласования по
|
||||
// ApproverId → Title/Status/Created заявки отдельным запросом к списку ДАННЫХ
|
||||
// ApproverId → Title/RequestStatus/Created заявки отдельным запросом к списку ДАННЫХ
|
||||
// (НЕ через expand по Lookup-полю Ticket — SharePoint REST не отдаёт так
|
||||
// произвольные поля связанного элемента, только через Person-поля вроде
|
||||
// Author; попытка `expand('Ticket').select('Ticket/Status', ...)` падает с 400).
|
||||
@@ -156,7 +238,8 @@ async function indexApprovalsForType(
|
||||
context: WebPartContext,
|
||||
type: IRequestType,
|
||||
tokenEmail: string,
|
||||
scope: ApprovalScope
|
||||
scope: ApprovalScope,
|
||||
statuses: string[]
|
||||
): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug }> {
|
||||
const debug: IMyRequestsTypeDebug = {
|
||||
requestTypeId: type.id,
|
||||
@@ -186,8 +269,8 @@ async function indexApprovalsForType(
|
||||
// SharePoint REST — резолвим email в числовой Id пользователя (как и везде в
|
||||
// проекте, см. ensureUser) и фильтруем по TansuSiteApproverId.
|
||||
//
|
||||
// RequestStatus в $filter НЕ используем: подтверждено на практике — фильтр
|
||||
// вида `RequestStatus eq 'На согласовании'` (кириллица в $filter) молча
|
||||
// ApprovalStatus в $filter НЕ используем: подтверждено на практике — фильтр
|
||||
// вида `ApprovalStatus eq 'На согласовании'` (кириллица в $filter) молча
|
||||
// возвращает 0 строк, даже когда без него те же строки находятся с этим же
|
||||
// значением статуса. Поэтому фильтруем только по TansuSiteApproverId
|
||||
// (числовое значение, проблем нет), а статус сравниваем в JS после
|
||||
@@ -208,23 +291,18 @@ async function indexApprovalsForType(
|
||||
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
|
||||
const recentRows = (await approvalList.items
|
||||
.filter(`TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'`)
|
||||
.select('Id', 'TicketId', 'RequestStatus')
|
||||
.select('Id', 'TicketId', 'ApprovalStatus')
|
||||
.top(2000)()) as Array<Record<string, unknown>>;
|
||||
|
||||
// Статусы уходят в KQL: сервер сразу отдаёт только активные строки,
|
||||
// Статусы уходят в KQL: сервер сразу отдаёт только нужные строки,
|
||||
// а не всё, где пользователь есть в маршруте.
|
||||
const searchRows = await getInvolvedRowsViaSearch(context, type, approvalList, [
|
||||
REQUEST_STATUS.onApproval,
|
||||
REQUEST_STATUS.onExecution,
|
||||
]);
|
||||
const searchRows = await getInvolvedRowsViaSearch(context, type, approvalList, statuses);
|
||||
const seenIds = new Set(recentRows.map((r) => r.Id));
|
||||
const allRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id)));
|
||||
|
||||
stepRows = allRows.filter(
|
||||
(r) =>
|
||||
r.RequestStatus === REQUEST_STATUS.onApproval ||
|
||||
r.RequestStatus === REQUEST_STATUS.onExecution
|
||||
);
|
||||
// REST-довесок берётся по Modified без учёта статуса (кириллица в $filter
|
||||
// ненадёжна, см. выше) — отбор по статусу делаем здесь, на клиенте.
|
||||
stepRows = allRows.filter((r) => statuses.indexOf(r.ApprovalStatus as string) !== -1);
|
||||
} else {
|
||||
// 'involved' — используется ТОЛЬКО для текстового поиска (см.
|
||||
// getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная
|
||||
@@ -233,7 +311,7 @@ async function indexApprovalsForType(
|
||||
// давал неустойчивый результат (заявка то находилась, то нет, в
|
||||
// зависимости от того, успел ли Search её проиндексировать к моменту
|
||||
// REST-окна).
|
||||
stepRows = await getInvolvedRowsViaSearch(context, type, approvalList);
|
||||
stepRows = await getInvolvedRowsViaSearch(context, type, approvalList, statuses);
|
||||
}
|
||||
|
||||
debug.approvalRowCount = stepRows.length;
|
||||
@@ -243,6 +321,18 @@ async function indexApprovalsForType(
|
||||
const ticketIds = Array.from(
|
||||
new Set(stepRows.map((r) => r.TicketId as number | undefined).filter((id): id is number => !!id))
|
||||
);
|
||||
|
||||
// Статус СВОЕЙ строки по заявке — нужен, чтобы разнести заявки по очередям
|
||||
// одним проходом (см. getMyTurnCounts). Если строк по заявке несколько,
|
||||
// берём первую подошедшую: все они уже отобраны по statuses, а очередь у
|
||||
// заявки на практике одна.
|
||||
const stepStatusByTicket = new Map<number, string>();
|
||||
stepRows.forEach((r) => {
|
||||
const ticketId = r.TicketId as number | undefined;
|
||||
if (ticketId && !stepStatusByTicket.has(ticketId)) {
|
||||
stepStatusByTicket.set(ticketId, (r.ApprovalStatus as string) || '');
|
||||
}
|
||||
});
|
||||
if (ticketIds.length === 0) {
|
||||
debug.approvalPendingTickets = 0;
|
||||
return { rows: [], debug };
|
||||
@@ -253,10 +343,13 @@ async function indexApprovalsForType(
|
||||
const ticketFilter = ticketIds.map((id) => `Id eq ${id}`).join(' or ');
|
||||
const ticketRows = (await list.items
|
||||
.filter(ticketFilter)
|
||||
.select('Id', 'Title', 'Status', 'Created')
|
||||
.select('Id', 'Title', 'RequestStatus', 'Created')
|
||||
.top(ticketIds.length)()) as Array<Record<string, unknown>>;
|
||||
|
||||
const rows = ticketRows.map((r) => toMyRequest(r, undefined, type, 'approval'));
|
||||
const rows = ticketRows.map((r) => ({
|
||||
...toMyRequest(r, undefined, type, 'approval'),
|
||||
myStepStatus: stepStatusByTicket.get(r.Id as number),
|
||||
}));
|
||||
|
||||
debug.approvalPendingTickets = rows.length;
|
||||
return { rows, debug };
|
||||
@@ -339,7 +432,7 @@ async function getInvolvedRowsViaSearch(
|
||||
|
||||
return (await approvalList.items
|
||||
.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
|
||||
.select('Id', 'TicketId', 'RequestStatus')
|
||||
.select('Id', 'TicketId', 'ApprovalStatus')
|
||||
.top(ids.length)()) as Array<Record<string, unknown>>;
|
||||
} catch (e) {
|
||||
console.error(
|
||||
@@ -482,7 +575,7 @@ async function loadByIds(
|
||||
const rows = (await sp.web
|
||||
.getList(type.listId as string)
|
||||
.items.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
|
||||
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
|
||||
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
||||
.expand('Author')
|
||||
.top(ids.length)()) as Array<Record<string, unknown>>;
|
||||
rows.forEach((r) => {
|
||||
@@ -494,23 +587,20 @@ async function loadByIds(
|
||||
return byKey;
|
||||
}
|
||||
|
||||
// Свежие (созданные за последние RECENT_MS) заявки автора — обычным REST-ом,
|
||||
// в обход Search (индекс мог их ещё не подхватить). Без текстового запроса —
|
||||
// матчить название не нужно, отдаём как есть.
|
||||
async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
|
||||
// Последние OWN_REST_TOP заявок автора одного типа обычным REST-ом, в обход
|
||||
// Search — сырьё для довеска по свежести (см. getFreshOwnRowsForType).
|
||||
// Фильтруем только по AuthorId: статус в $filter не используем, потому что
|
||||
// кириллица в сравнении там уже подводила (см. indexApprovalsForType) — отбор
|
||||
// по статусу делает вызывающая сторона.
|
||||
async function getOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
|
||||
try {
|
||||
const sp = spForSite(type.siteUrl as string, context);
|
||||
const me = await sp.web.currentUser.select('Id')();
|
||||
const cutoff = new Date(Date.now() - RECENT_MS).toISOString();
|
||||
|
||||
// Фильтруем только по AuthorId, а «свежая или черновик» решаем в JS: статус
|
||||
// в $filter не используем, потому что кириллица в сравнении там уже подводила
|
||||
// (см. indexApprovalsForType). Берём последние OWN_REST_TOP заявок автора —
|
||||
// это его собственные записи, их немного, и запрос ограничен по объёму.
|
||||
const rows = (await sp.web
|
||||
.getList(type.listId as string)
|
||||
.items.filter(`AuthorId eq ${me.Id}`)
|
||||
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
|
||||
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
||||
.expand('Author')
|
||||
.orderBy('Created', false)
|
||||
.top(OWN_REST_TOP)()) as Array<Record<string, unknown>>;
|
||||
@@ -521,17 +611,57 @@ async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestTyp
|
||||
);
|
||||
}
|
||||
|
||||
// Черновик берём ЛЮБОГО возраста: он живёт только у автора, правится часто и
|
||||
// именно по нему индекс Search отстаёт заметнее всего, а увидеть свой
|
||||
// черновик в «Моих заявках» пользователь ожидает сразу.
|
||||
return rows
|
||||
.filter((r) => {
|
||||
const created = (r.Created as string) || '';
|
||||
return r.Status === REQUEST_STATUS.draft || created >= cutoff;
|
||||
})
|
||||
.map((r) => toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own'));
|
||||
return rows.map((r) =>
|
||||
toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own')
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(`[Текущие заявки] «${type.name}»: не удалось дочитать свежие заявки:`, (e as Error).message);
|
||||
console.error(`[Текущие заявки] «${type.name}»: не удалось прочитать свои заявки:`, (e as Error).message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Свежие (созданные за последние RECENT_MS) заявки автора — довесок к Search,
|
||||
// индекс мог их ещё не подхватить.
|
||||
async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
|
||||
const cutoff = new Date(Date.now() - RECENT_MS).toISOString();
|
||||
|
||||
// Черновик берём ЛЮБОГО возраста: он живёт только у автора, правится часто и
|
||||
// именно по нему индекс Search отстаёт заметнее всего, а увидеть свой
|
||||
// черновик в «Моих заявках» пользователь ожидает сразу.
|
||||
return (await getOwnRowsForType(context, type)).filter(
|
||||
(r) => r.status === REQUEST_STATUS.draft || r.created >= cutoff
|
||||
);
|
||||
}
|
||||
|
||||
// Заявки автора, изменённые за последние RECENT_MS, — довесок к Search для
|
||||
// getOwnRequestsByStatus. В отличие от getFreshOwnRowsForType (которая ловит
|
||||
// НОВЫЕ заявки по дате СОЗДАНИЯ), здесь важна дата ИЗМЕНЕНИЯ: заявку могли
|
||||
// вернуть на доработку неделю спустя после создания — по Created такую не
|
||||
// найти, а Modified у неё обновится ровно в момент решения согласующего
|
||||
// (см. approverDecision.service.ts/decide). Filter по Modified — обычная дата,
|
||||
// не Choice-колонка, кириллицы в значении нет, поэтому $filter здесь надёжен
|
||||
// (в отличие от статуса, см. getOwnRequestsByStatus).
|
||||
async function getRecentlyChangedOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
|
||||
try {
|
||||
const sp = spForSite(type.siteUrl as string, context);
|
||||
const me = await sp.web.currentUser.select('Id')();
|
||||
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
|
||||
|
||||
const rows = (await sp.web
|
||||
.getList(type.listId as string)
|
||||
.items.filter(`AuthorId eq ${me.Id} and Modified ge '${cutoffIso}'`)
|
||||
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
||||
.expand('Author')
|
||||
.top(OWN_REST_TOP)()) as Array<Record<string, unknown>>;
|
||||
|
||||
return rows.map((r) =>
|
||||
toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own')
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[Текущие заявки] «${type.name}»: не удалось прочитать недавно изменённые заявки:`,
|
||||
(e as Error).message
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -601,12 +731,153 @@ export async function searchOwnRequests(
|
||||
});
|
||||
|
||||
|
||||
const totalRows = results.TotalRows || 0;
|
||||
const entries = hitsToEntries(results, types);
|
||||
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
|
||||
// Порядок задаёт релевантность поиска, а не порядок ответов REST.
|
||||
const rows = entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r);
|
||||
|
||||
return { rows, totalRows: results.TotalRows || 0 };
|
||||
return { rows, totalRows };
|
||||
}
|
||||
|
||||
// Свои заявки с конкретным статусом ЗАЯВКИ (не строки маршрута) — «Доработка»
|
||||
// и фильтр по статусу на вкладке «Инициированные».
|
||||
//
|
||||
// Статус НЕ уходит в KQL: единственная надёжная managed property здесь —
|
||||
// AuthorOWSUSER (стандартное поле автора), а для произвольной Choice-колонки
|
||||
// имя managed property надо либо знать из схемы поиска, либо угадывать по
|
||||
// конвенции — угадали неверно (или она ещё не переиндексирована после
|
||||
// переименования колонки Status → RequestStatus), из-за чего фильтр по
|
||||
// статусу молча возвращал 0. Поэтому здесь Search ищет ТОЛЬКО по автору
|
||||
// (+ опционально текст) — это находит ВСЕ заявки автора вплоть до
|
||||
// KQL_ID_LIMIT, — а отбор по статусу и постраничность делаются в памяти по
|
||||
// уже дочитанным через REST реальным полям (см. searchOwnAll/loadByIds).
|
||||
// Тот же принцип, что и в filterPendingByQuery: Search сужает набор по
|
||||
// надёжному полю, остальное — на клиенте.
|
||||
export async function getOwnRequestsByStatus(
|
||||
context: WebPartContext,
|
||||
types: IRequestType[],
|
||||
status: string,
|
||||
page: number,
|
||||
query: string = ''
|
||||
): Promise<IOwnRequestsPage> {
|
||||
const all = await searchOwnAll(context, types, query);
|
||||
const searchFiltered = all.filter((r) => r.status === status);
|
||||
|
||||
// Довесок по недавним изменениям — только на первой странице и только без
|
||||
// текстового поиска (см. getOwnRequests: тот же принцип — довесок относится
|
||||
// к «показать всё, свежее по дате», а не к результату полнотекстового поиска).
|
||||
let merged = searchFiltered;
|
||||
if (page === 1 && !query.trim()) {
|
||||
const freshRows = (await Promise.all(types.map((t) => getRecentlyChangedOwnRowsForType(context, t))))
|
||||
.reduce((acc, r) => acc.concat(r), [] as IMyRequest[])
|
||||
.filter((r) => r.status === status);
|
||||
|
||||
const seen = new Set(searchFiltered.map((r) => `${r.requestTypeId}-${r.id}`));
|
||||
const onlyFresh = freshRows.filter((r) => !seen.has(`${r.requestTypeId}-${r.id}`));
|
||||
merged = onlyFresh.concat(searchFiltered);
|
||||
}
|
||||
|
||||
const sorted = merged.sort((a, b) => b.created.localeCompare(a.created));
|
||||
return {
|
||||
rows: sorted.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||
totalRows: sorted.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Список согласования по типу заявки: Title списка данных → Title списка
|
||||
// согласования по конвенции имён (та же логика, что в indexApprovalsForType).
|
||||
// undefined — список заведён не по конвенции, маршрута для него не найти.
|
||||
async function approvalListFor(
|
||||
context: WebPartContext,
|
||||
type: IRequestType
|
||||
): Promise<IList | undefined> {
|
||||
const sp = spForSite(type.siteUrl as string, context);
|
||||
const info = (await sp.web.getList(type.listId as string).select('Title')()) as { Title?: string };
|
||||
const dataTitle = info.Title || '';
|
||||
if (!DATA_LIST_SUFFIX.test(dataTitle)) return undefined;
|
||||
return sp.web.lists.getByTitle(`${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`);
|
||||
}
|
||||
|
||||
// Сколько строк маршрута максимум забираем за раз при дочитывании комментариев:
|
||||
// на странице ≤20 заявок, у каждой обычно единицы шагов — запас многократный.
|
||||
const DECISION_COMMENT_ROW_LIMIT = 2000;
|
||||
|
||||
/**
|
||||
* Проставляет decisionComment — комментарий согласующего к его решению по
|
||||
* заявке: причину возврата на доработку либо отклонения (что именно искать,
|
||||
* задаёт decisionStatus). Комментарий хранится в СТРОКЕ МАРШРУТА (колонка
|
||||
* Comment списка «— согласование»), а не в самой заявке, поэтому его нельзя
|
||||
* получить тем же запросом, что и список: нужен отдельный проход по спискам
|
||||
* согласования.
|
||||
*
|
||||
* Одного статуса достаточно, чтобы найти нужную строку, и для «своих
|
||||
* возвращённых» (вернул кто-то другой), и для «отклонённых мной»: и возврат, и
|
||||
* отклонение останавливают маршрут, поэтому такая строка по заявке одна.
|
||||
*
|
||||
* Вызывается ТОЛЬКО там, где причина показывается (см. RequestsTableView) —
|
||||
* счётчику в меню, например, она не нужна, и лишние запросы ему ни к чему.
|
||||
* Мутирует переданные строки — как и hydrateAuthors рядом.
|
||||
*/
|
||||
export async function hydrateDecisionComments(
|
||||
context: WebPartContext,
|
||||
types: IRequestType[],
|
||||
rows: IMyRequest[],
|
||||
decisionStatus: string
|
||||
): Promise<void> {
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const typeById = new Map<number, IRequestType>();
|
||||
types.forEach((t) => typeById.set(t.id, t));
|
||||
|
||||
const rowsByType = new Map<number, IMyRequest[]>();
|
||||
rows.forEach((r) => {
|
||||
const list = rowsByType.get(r.requestTypeId) || [];
|
||||
list.push(r);
|
||||
rowsByType.set(r.requestTypeId, list);
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
Array.from(rowsByType.entries()).map(async ([requestTypeId, typeRows]) => {
|
||||
const type = typeById.get(requestTypeId);
|
||||
if (!type) return;
|
||||
try {
|
||||
const approvalList = await approvalListFor(context, type);
|
||||
if (!approvalList) return;
|
||||
|
||||
// Фильтруем только по TicketId (числовое поле), статус сравниваем в JS:
|
||||
// кириллица в $filter по Choice-колонке молча возвращает 0 строк —
|
||||
// ровно та же причина, что и в indexApprovalsForType.
|
||||
const stepRows = (await approvalList.items
|
||||
.filter(typeRows.map((r) => `TicketId eq ${r.id}`).join(' or '))
|
||||
.select('TicketId', 'ApprovalStatus', 'Comment')
|
||||
.top(DECISION_COMMENT_ROW_LIMIT)()) as Array<Record<string, unknown>>;
|
||||
|
||||
const commentByTicket = new Map<number, string>();
|
||||
stepRows.forEach((r) => {
|
||||
if (r.ApprovalStatus !== decisionStatus) return;
|
||||
const ticketId = r.TicketId as number | undefined;
|
||||
const comment = ((r.Comment as string) || '').trim();
|
||||
// Такая строка по заявке одна (и возврат, и отклонение останавливают
|
||||
// маршрут, а при переотправке он переписывается целиком — см.
|
||||
// writeRoute), но если их вдруг несколько, берём первую НЕПУСТУЮ,
|
||||
// а не последнюю пустую.
|
||||
if (ticketId && comment && !commentByTicket.has(ticketId)) {
|
||||
commentByTicket.set(ticketId, comment);
|
||||
}
|
||||
});
|
||||
|
||||
typeRows.forEach((r) => {
|
||||
r.decisionComment = commentByTicket.get(r.id);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[Текущие заявки] «${type.name}»: не удалось прочитать комментарий к решению:`,
|
||||
(e as Error).message
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// «На согласовании»: список заявок уже отобран по строкам согласования
|
||||
@@ -672,7 +943,7 @@ export interface ISearchPage {
|
||||
|
||||
// Как searchOwnRequests, но без $skip/$top — сразу весь найденный срез (до
|
||||
// KQL_ID_LIMIT). Только Search, без REST-подстраховки на свежесть.
|
||||
async function searchOwnAll(
|
||||
export async function searchOwnAll(
|
||||
context: WebPartContext,
|
||||
types: IRequestType[],
|
||||
query: string
|
||||
@@ -702,11 +973,25 @@ async function searchOwnAll(
|
||||
return entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r);
|
||||
}
|
||||
|
||||
/**
|
||||
* ВЕСЬ результат поиска (без нарезки на страницы) — постраничность делает
|
||||
* вызывающая сторона.
|
||||
*
|
||||
* Именно так, а не «страницу за вызов»: набор всё равно собирается целиком
|
||||
* (двумя источниками + слияние + сортировка), и раньше 95% этой работы
|
||||
* выбрасывалось ради 20 строк — а при переходе на следующую страницу весь поиск
|
||||
* запускался заново. Теперь SearchView кеширует список по ЗАПРОСУ, и листание
|
||||
* страниц не стоит ни одного запроса.
|
||||
*
|
||||
* Автор здесь НЕ дочитывается: у строк 'approval' он требует отдельного запроса,
|
||||
* и делать его на весь набор (до KQL_ID_LIMIT) значило бы собирать $filter из
|
||||
* сотен Id — SharePoint такой длины URL не переварит. Автора дочитывает
|
||||
* вызывающая сторона для видимой страницы (hydratePendingAuthors).
|
||||
*/
|
||||
export async function searchMyRequests(
|
||||
context: WebPartContext,
|
||||
types: IRequestType[],
|
||||
query: string,
|
||||
page: number
|
||||
query: string
|
||||
): Promise<ISearchPage> {
|
||||
const q = query.trim();
|
||||
if (!q) return { rows: [], totalRows: 0 };
|
||||
@@ -724,17 +1009,7 @@ export async function searchMyRequests(
|
||||
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 сам дочитывает Author).
|
||||
const needAuthor = pageRows.filter((r) => r.kind === 'approval');
|
||||
if (needAuthor.length > 0) {
|
||||
await hydratePendingAuthors(context, involvedIdx.types, needAuthor);
|
||||
}
|
||||
|
||||
return { rows: pageRows, totalRows };
|
||||
return { rows: merged, totalRows: merged.length };
|
||||
}
|
||||
|
||||
function toMyRequest(
|
||||
@@ -746,7 +1021,7 @@ function toMyRequest(
|
||||
return {
|
||||
id: r.Id as number,
|
||||
title: (r.Title as string) || '',
|
||||
status: (r.Status as string) || '',
|
||||
status: (r.RequestStatus as string) || '',
|
||||
authorEmail: author?.EMail || '',
|
||||
authorName: author?.Title || '',
|
||||
created: (r.Created as string) || '',
|
||||
|
||||
@@ -99,6 +99,18 @@ describe('pageSignature', () => {
|
||||
expect(pageSignature(before)).toBe(pageSignature(after));
|
||||
});
|
||||
|
||||
it('изменение комментария к решению меняет подпись — он показан колонкой «Причина возврата/отклонения»', () => {
|
||||
const before = page({ rows: [request({ decisionComment: 'Исправьте должность' })] });
|
||||
const after = page({ rows: [request({ decisionComment: 'Приложите скан' })] });
|
||||
expect(pageSignature(before)).not.toBe(pageSignature(after));
|
||||
});
|
||||
|
||||
it('появление комментария к решению у строки без него тоже меняет подпись', () => {
|
||||
const before = page({ rows: [request()] });
|
||||
const after = page({ rows: [request({ decisionComment: 'Исправьте должность' })] });
|
||||
expect(pageSignature(before)).not.toBe(pageSignature(after));
|
||||
});
|
||||
|
||||
it('пустая страница имеет стабильную подпись', () => {
|
||||
expect(pageSignature({ rows: [], totalRows: 0 })).toBe(pageSignature({ rows: [], totalRows: 0 }));
|
||||
});
|
||||
|
||||
@@ -38,6 +38,8 @@ export function writePage(key: string, page: IPage): void {
|
||||
// перерисовываясь тем же самым содержимым.
|
||||
export function pageSignature(page: IPage): string {
|
||||
return `${page.totalRows}|${page.rows
|
||||
.map((r) => [r.requestTypeId, r.id, r.title, r.status, r.created, r.authorName].join('~'))
|
||||
.map((r) =>
|
||||
[r.requestTypeId, r.id, r.title, r.status, r.created, r.authorName, r.decisionComment ?? ''].join('~')
|
||||
)
|
||||
.join(';')}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user