куча доработок
This commit is contained in:
@@ -2,7 +2,7 @@ import * as React from 'react';
|
||||
import styles from './RequestPortal.module.scss';
|
||||
import type { IRequestPortalProps } from './IRequestPortalProps';
|
||||
import { IconSprite } from '../shared/ui/Icon/IconSprite';
|
||||
import { navigate, useRoute, NavSection, OwnTab } from '../shared/routing';
|
||||
import { navigate, persistOwnSelection, useRoute, NavSection, OwnTab } from '../shared/routing';
|
||||
import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents';
|
||||
import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges';
|
||||
import { SideNav } from './SideNav/SideNav';
|
||||
@@ -110,28 +110,27 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
// таблица на него подписана и перечитает данные фоном, не убирая с экрана
|
||||
// текущие строки (см. RequestsTableView/SearchView).
|
||||
if (nav === route.nav && route.mode === 'submit') emitRequestsChanged();
|
||||
// Возврат в «Мои заявки» открывает ту же подвкладку и с тем же фильтром,
|
||||
// что были в прошлый раз, — иначе уход в другой раздел и обратно сбрасывал
|
||||
// бы выбор пользователя.
|
||||
navigate(
|
||||
nav === 'own'
|
||||
? { mode: 'submit', nav, ownTab: route.ownTab, ownStatus: route.ownStatus }
|
||||
: { mode: 'submit', nav }
|
||||
);
|
||||
// Подвкладку/фильтр «Моих заявок» здесь передавать не нужно: при переходе
|
||||
// ИЗ другого раздела route.ownTab всё равно undefined (parseRoute заполняет
|
||||
// его только когда nav==='own'), а при возврате на уже открытую «own» их
|
||||
// подхватит storage-фолбэк в parseRoute — то же самое значение, которое
|
||||
// пользователь выбирал последним (см. persistOwnSelection/gotoOwnTab).
|
||||
navigate({ mode: 'submit', nav });
|
||||
};
|
||||
|
||||
const gotoOwnTab = (ownTab: OwnTab): void =>
|
||||
const gotoOwnTab = (ownTab: OwnTab): void => {
|
||||
// Фильтр по статусу относится только к «Инициированным» — на других
|
||||
// подвкладках отбор задаёт сама вкладка, поэтому фильтр туда не тащим.
|
||||
navigate({
|
||||
mode: 'submit',
|
||||
nav: 'own',
|
||||
ownTab,
|
||||
ownStatus: ownTab === 'created' ? route.ownStatus : undefined,
|
||||
});
|
||||
const ownStatus = ownTab === 'created' ? route.ownStatus : undefined;
|
||||
persistOwnSelection(ownTab, ownStatus);
|
||||
navigate({ mode: 'submit', nav: 'own', ownTab, ownStatus });
|
||||
};
|
||||
|
||||
const setOwnStatus = (ownStatus: string): void =>
|
||||
navigate({ mode: 'submit', nav: 'own', ownTab: 'created', ownStatus: ownStatus || undefined });
|
||||
const setOwnStatus = (ownStatus: string): void => {
|
||||
const normalized = ownStatus || undefined;
|
||||
persistOwnSelection('created', normalized);
|
||||
navigate({ mode: 'submit', nav: 'own', ownTab: 'created', ownStatus: normalized });
|
||||
};
|
||||
// Клик по строке таблицы. СВОЯ заявка в незавершённом статусе (черновик,
|
||||
// возвращённая, отклонённая) открывается на редактирование, всё остальное —
|
||||
// на просмотр: чужую заявку править нельзя, а свою уже ушедшую по маршруту —
|
||||
|
||||
@@ -187,41 +187,7 @@
|
||||
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;
|
||||
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"). Раньше здесь была своя копия этих цветов —
|
||||
// она разошлась с эталоном (На согласовании/Возвращена на доработку оказались
|
||||
// перепутаны), поэтому копию убрали, а не подправили.
|
||||
|
||||
@@ -7,11 +7,12 @@ import {
|
||||
getApprovalQueueIndex,
|
||||
getLinkedTypes,
|
||||
getOwnRequests,
|
||||
getOwnRequestsByStatus,
|
||||
hydratePendingAuthors,
|
||||
searchOwnRequests,
|
||||
} from './myRequests.service';
|
||||
import { IMyRequest } from './models/IMyRequest';
|
||||
import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, 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 { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||||
@@ -103,23 +104,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;
|
||||
}
|
||||
}
|
||||
|
||||
// Таблица заявок. Клик в ЛЮБОМ месте строки открывает заявку (кликабельна вся
|
||||
// строка, а не только название), поэтому строка — <tr> с onClick + tabIndex
|
||||
// (доступ с клавиатуры: Enter/Space).
|
||||
@@ -173,7 +157,7 @@ const Table: React.FC<{
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</tr>
|
||||
@@ -205,9 +189,12 @@ const Table: React.FC<{
|
||||
// после фильтрации — нарезка на страницы и дочитывание автора
|
||||
// (hydratePendingAuthors) — тоже только для видимой страницы.
|
||||
//
|
||||
// Отбор по статусу ЗАЯВКИ ('rework' и фильтр на 'own:created') уходит в тот же
|
||||
// KQL отдельным условием (см. MP_TICKET_STATUS) — то есть работает вместе с
|
||||
// текстовым поиском и с серверной постраничностью, без вычитывания архива.
|
||||
// Отбор по статусу ЗАЯВКИ ('rework' и фильтр на 'own:created') — НЕ через KQL:
|
||||
// managed property для произвольной Choice-колонки ненадёжна (см.
|
||||
// getOwnRequestsByStatus в myRequests.service.ts — там объяснение и ссылка на
|
||||
// реальный инцидент). Вместо этого Search ищет только по автору (надёжная
|
||||
// AuthorOWSUSER), а статус фильтруется в памяти по полям, дочитанным через
|
||||
// REST; постраничность там же, в памяти.
|
||||
//
|
||||
// Все варианты перечитываются по событию rp-requests-changed
|
||||
// (shared/requestEvents) — после создания заявки и после решения согласующего.
|
||||
@@ -263,13 +250,17 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
const q = debouncedQuery.trim();
|
||||
|
||||
const fetchPage = async (): Promise<IPage> => {
|
||||
// Свои заявки: «Доработка» — тот же список, что «Инициированные», просто
|
||||
// со статусом, зашитым в раздел, а не выбранным в фильтре.
|
||||
if (view === 'rework' || view === 'own:created') {
|
||||
// «Доработка» — тот же список, что «Инициированные», просто со статусом,
|
||||
// зашитым в раздел, а не выбранным в фильтре.
|
||||
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, status)
|
||||
: getOwnRequests(context, types, page, status);
|
||||
? searchOwnRequests(context, types, debouncedQuery, page)
|
||||
: getOwnRequests(context, types, page);
|
||||
}
|
||||
|
||||
const queue = QUEUE_BY_VIEW[view];
|
||||
|
||||
@@ -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"). Раньше здесь была своя копия этих цветов —
|
||||
// она разошлась с эталоном (На согласовании/Возвращена на доработку оказались
|
||||
// перепутаны), поэтому копию убрали, а не подправили.
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { IRequestType, StatusBadge } from '../../shared/approval';
|
||||
import { onRequestsChanged } from '../../shared/requestEvents';
|
||||
import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery';
|
||||
import { pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||||
@@ -32,23 +32,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 +74,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>
|
||||
|
||||
@@ -41,20 +41,18 @@ const MP_APPROVER = 'TansuSiteApproverOWSUSER';
|
||||
// Статус СТРОКИ МАРШРУТА (колонка ApprovalStatus списка «— согласование») —
|
||||
// Choice-поле, отсюда суффикс OWSCHCS (подтверждено отладочным перебором
|
||||
// 2026-08-06; у Person-полей он OWSUSER, см. MP_APPROVER). Колонку не путать с
|
||||
// RequestStatus списка ДАННЫХ — это статус ЗАЯВКИ целиком, у него своя
|
||||
// managed property (см. MP_TICKET_STATUS ниже).
|
||||
// RequestStatus списка ДАННЫХ — это статус ЗАЯВКИ целиком.
|
||||
//
|
||||
// Для статуса ЗАЯВКИ (список ДАННЫХ) отдельной managed property здесь
|
||||
// НАМЕРЕННО нет: имя для произвольной Choice-колонки в схеме поиска либо надо
|
||||
// знать точно, либо угадывать по конвенции — угадали неверно (или колонка
|
||||
// после переименования Status → RequestStatus ещё не переиндексирована), и
|
||||
// фильтр по статусу молча возвращал 0 заявок. Поэтому статус заявки
|
||||
// фильтруется НЕ в KQL, а в памяти после дочитывания реальных полей через REST
|
||||
// (см. getOwnRequestsByStatus) — Search там ищет только по надёжному
|
||||
// AuthorOWSUSER, статус ни при чём.
|
||||
const MP_STATUS = 'ApprovalStatusOWSCHCS';
|
||||
|
||||
// То же самое для Choice-колонки «Статус» списка ДАННЫХ (внутреннее имя
|
||||
// RequestStatus, см. forms/*/provisioning.json) — по ней отбираются
|
||||
// «Доработка» и фильтр по статусу в «Моих заявках». Кириллица в значении
|
||||
// проблемы не создаёт: KQL — не $filter (там она действительно подводит, см.
|
||||
// indexApprovalsForType), и по MP_STATUS выше точно так же ищутся русские
|
||||
// статусы строк маршрута.
|
||||
// Если отбор по статусу вдруг начнёт стабильно возвращать 0 — проверять надо в
|
||||
// первую очередь ЭТО имя в схеме поиска (см. предупреждение в searchOwnRequests).
|
||||
const MP_TICKET_STATUS = 'RequestStatusOWSCHCS';
|
||||
|
||||
export function currentUserEmail(context: WebPartContext): string {
|
||||
return (context.pageContext.user.email || '').toLowerCase();
|
||||
}
|
||||
@@ -210,7 +208,7 @@ export async function getNavCounts(context: WebPartContext): Promise<INavCounts>
|
||||
|
||||
const [turn, rework] = await Promise.all([
|
||||
buildPendingIndex(context, 'myTurn', MY_TURN_STATUSES),
|
||||
getOwnRequests(context, types, 1, REQUEST_STATUS.returned),
|
||||
getOwnRequestsByStatus(context, types, REQUEST_STATUS.returned, 1),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -593,8 +591,7 @@ async function loadByIds(
|
||||
// Search — сырьё для довеска по свежести (см. getFreshOwnRowsForType).
|
||||
// Фильтруем только по AuthorId: статус в $filter не используем, потому что
|
||||
// кириллица в сравнении там уже подводила (см. indexApprovalsForType) — отбор
|
||||
// по статусу делает вызывающая сторона. В самом Search кириллица проблемой не
|
||||
// является, там статус уходит прямо в KQL (см. MP_TICKET_STATUS).
|
||||
// по статусу делает вызывающая сторона.
|
||||
async function getOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
|
||||
try {
|
||||
const sp = spForSite(type.siteUrl as string, context);
|
||||
@@ -636,6 +633,39 @@ async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestTyp
|
||||
);
|
||||
}
|
||||
|
||||
// Заявки автора, изменённые за последние 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 [];
|
||||
}
|
||||
}
|
||||
|
||||
// «Мои заявки» БЕЗ поиска — гибрид для масштаба: Search с серверной пагинацией
|
||||
// (RowLimit/StartRow) находит страницу без сканирования всего архива, REST по
|
||||
// «последним N минутам» добирает то, что Search мог не успеть проиндексировать
|
||||
@@ -644,20 +674,18 @@ async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestTyp
|
||||
export async function getOwnRequests(
|
||||
context: WebPartContext,
|
||||
types: IRequestType[],
|
||||
page: number,
|
||||
status: string = ''
|
||||
page: number
|
||||
): Promise<IOwnRequestsPage> {
|
||||
if (types.length === 0) return { rows: [], totalRows: 0 };
|
||||
|
||||
const searchPage = await searchOwnRequests(context, types, '', page, status);
|
||||
const searchPage = await searchOwnRequests(context, types, '', page);
|
||||
|
||||
if (page !== 1) return searchPage;
|
||||
|
||||
// Довесок по свежести отбираем по статусу здесь: он приходит REST-ом, где
|
||||
// сравнивать статус в $filter нельзя (кириллица), — зато набор маленький.
|
||||
const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t))))
|
||||
.reduce((all, r) => all.concat(r), [] as IMyRequest[])
|
||||
.filter((r) => !status || r.status === status);
|
||||
const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t)))).reduce(
|
||||
(all, r) => all.concat(r),
|
||||
[] as IMyRequest[]
|
||||
);
|
||||
|
||||
const searchKeys = new Set(searchPage.rows.map((r) => `${r.requestTypeId}-${r.id}`));
|
||||
const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`));
|
||||
@@ -677,8 +705,7 @@ export async function searchOwnRequests(
|
||||
context: WebPartContext,
|
||||
types: IRequestType[],
|
||||
query: string,
|
||||
page: number,
|
||||
status: string = ''
|
||||
page: number
|
||||
): Promise<IOwnRequestsPage> {
|
||||
if (types.length === 0) return { rows: [], totalRows: 0 };
|
||||
|
||||
@@ -689,9 +716,6 @@ export async function searchOwnRequests(
|
||||
`(${types.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`,
|
||||
`AuthorOWSUSER:"${claimsLogin}"`,
|
||||
];
|
||||
// Отбор по статусу заявки — на СЕРВЕРЕ, вместе с постраничностью: иначе
|
||||
// пришлось бы вычитывать весь архив автора и резать его в памяти.
|
||||
if (status) clauses.push(`${MP_TICKET_STATUS}:"${status}"`);
|
||||
const text = freeTextClause(query);
|
||||
if (text) clauses.push(text);
|
||||
const kql = clauses.join(' AND ');
|
||||
@@ -708,18 +732,6 @@ export async function searchOwnRequests(
|
||||
|
||||
|
||||
const totalRows = results.TotalRows || 0;
|
||||
|
||||
// Отбор по статусу опирается на managed property, имя которой мы выводим по
|
||||
// конвенции, а не читаем из схемы (см. MP_TICKET_STATUS). Если она названа
|
||||
// иначе или не заведена, запрос отработает без ошибки и просто вернёт 0 —
|
||||
// такое молчание слишком дорого отлаживать вслепую, поэтому подсказываем.
|
||||
if (status && totalRows === 0) {
|
||||
console.warn(
|
||||
`[Мои заявки] По статусу «${status}» ничего не найдено. Если так на ЛЮБОМ статусе — ` +
|
||||
`проверьте managed property «${MP_TICKET_STATUS}» в схеме поиска SharePoint.`
|
||||
);
|
||||
}
|
||||
|
||||
const entries = hitsToEntries(results, types);
|
||||
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
|
||||
// Порядок задаёт релевантность поиска, а не порядок ответов REST.
|
||||
@@ -728,6 +740,51 @@ export async function searchOwnRequests(
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// «На согласовании»: список заявок уже отобран по строкам согласования
|
||||
// (getPendingApprovalIndex — там же определяется, чья сейчас очередь,
|
||||
// обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search
|
||||
@@ -791,7 +848,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
|
||||
|
||||
@@ -12,6 +12,16 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Компактный вариант — для ячейки таблицы (RequestsTableView/SearchView),
|
||||
// повторяет размеры, что были у их собственной .status до объединения.
|
||||
.sm {
|
||||
padding: 3px 10px;
|
||||
border-radius: $r-md;
|
||||
font-size: $fs-caption;
|
||||
font-weight: 600;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.draft {
|
||||
background: $gray-100;
|
||||
color: $gray-700;
|
||||
|
||||
@@ -16,7 +16,25 @@ const VARIANT: Record<string, string> = {
|
||||
[REQUEST_STATUS.closed]: styles.closed,
|
||||
};
|
||||
|
||||
/** Цветная плашка статуса заявки (шапка формы / просмотра). */
|
||||
export const StatusBadge: React.FC<{ status: string }> = ({ status }) => (
|
||||
<span className={`${styles.badge} ${VARIANT[status] || styles.draft}`}>{status}</span>
|
||||
export interface IStatusBadgeProps {
|
||||
status: string;
|
||||
/**
|
||||
* 'md' (по умолчанию) — шапка формы/просмотра заявки; 'sm' — компактный
|
||||
* вариант для ячейки таблицы (RequestsTableView/SearchView). Цвет статуса
|
||||
* не зависит от размера — единственный источник цвета: VARIANT выше.
|
||||
*/
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
/**
|
||||
* Цветная плашка статуса заявки — ЕДИНСТВЕННОЕ место, где статус превращается
|
||||
* в цвет. У таблиц раньше была своя копия этой раскраски (statusClass в
|
||||
* RequestsTableView.tsx/SearchView.tsx) — она разошлась с этой (на согласовании
|
||||
* и возвращена на доработку оказались перепутаны местами), поэтому обе копии
|
||||
* убраны в пользу общего компонента.
|
||||
*/
|
||||
export const StatusBadge: React.FC<IStatusBadgeProps> = ({ status, size = 'md' }) => (
|
||||
<span className={`${styles.badge} ${size === 'sm' ? styles.sm : ''} ${VARIANT[status] || styles.draft}`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { IRoute, navigate, parseRoute, routeUrl } from './routing';
|
||||
import { IRoute, navigate, parseRoute, persistOwnSelection, routeUrl } from './routing';
|
||||
|
||||
// buildQuery/navigate читают и пишут window.location через history API —
|
||||
// сбрасываем адрес перед каждым тестом, чтобы они не зависели от порядка запуска.
|
||||
// buildQuery/navigate читают и пишут window.location через history API, а
|
||||
// подвкладка/фильтр «Моих заявок» — ещё и localStorage (см. persistOwnSelection) —
|
||||
// сбрасываем оба перед каждым тестом, чтобы они не зависели от порядка запуска.
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe('parseRoute', () => {
|
||||
@@ -124,6 +126,53 @@ describe('подвкладки и фильтр «Моих заявок»', () =>
|
||||
expect(window.location.search).not.toContain('rptab');
|
||||
expect(window.location.search).not.toContain('rpstatus');
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistOwnSelection — запоминание подвкладки/фильтра между визитами', () => {
|
||||
it('без явного rptab в адресе подставляется запомненная подвкладка', () => {
|
||||
persistOwnSelection('rejected', undefined);
|
||||
expect(parseRoute('?rpnav=own').ownTab).toBe('rejected');
|
||||
});
|
||||
|
||||
it('без явного rpstatus подставляется запомненный фильтр (только на «Инициированных»)', () => {
|
||||
persistOwnSelection('created', 'Черновик');
|
||||
expect(parseRoute('?rpnav=own').ownStatus).toBe('Черновик');
|
||||
// На вкладках с решениями запомненный фильтр ни при чём — там его нет по смыслу.
|
||||
persistOwnSelection('approved', 'Черновик');
|
||||
expect(parseRoute('?rpnav=own').ownStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
it('явный параметр в адресе имеет приоритет над запомненным значением', () => {
|
||||
persistOwnSelection('rejected', undefined);
|
||||
expect(parseRoute('?rpnav=own&rptab=executed').ownTab).toBe('executed');
|
||||
});
|
||||
|
||||
it('очистка фильтра persistOwnSelection убирает его и из последующих parseRoute', () => {
|
||||
persistOwnSelection('created', 'Черновик');
|
||||
expect(parseRoute('?rpnav=own').ownStatus).toBe('Черновик');
|
||||
|
||||
persistOwnSelection('created', undefined);
|
||||
expect(parseRoute('?rpnav=own').ownStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
it('запомненное значение не протекает за пределы раздела «Мои заявки»', () => {
|
||||
persistOwnSelection('rejected', undefined);
|
||||
expect(parseRoute('?rpnav=approvals').ownTab).toBeUndefined();
|
||||
});
|
||||
|
||||
it('localStorage недоступен (например, приватный режим) — не роняет parseRoute', () => {
|
||||
const original = window.localStorage.getItem;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- имитация приватного режима, где чтение localStorage бросает
|
||||
(window.localStorage as any).getItem = (): never => {
|
||||
throw new Error('SecurityError');
|
||||
};
|
||||
try {
|
||||
expect(() => parseRoute('?rpnav=own')).not.toThrow();
|
||||
expect(parseRoute('?rpnav=own').ownTab).toBe('created');
|
||||
} finally {
|
||||
window.localStorage.getItem = original;
|
||||
}
|
||||
});
|
||||
|
||||
it('round-trip для подвкладки решений', () => {
|
||||
navigate({ mode: 'submit', nav: 'own', ownTab: 'executed' });
|
||||
|
||||
@@ -62,6 +62,54 @@ export interface IRoute {
|
||||
|
||||
const ROUTE_CHANGE_EVENT = 'rp-route-change';
|
||||
|
||||
// Подвкладка и фильтр «Моих заявок» переживают перезагрузку страницы и новый
|
||||
// визит (localStorage), но НЕ более того — как и текст поиска в
|
||||
// usePersistedQuery.ts, это просто «продолжить с того же места», а не
|
||||
// синхронизация между вкладками/устройствами.
|
||||
//
|
||||
// Читаются здесь же, в parseRoute, а не отдельным хуком в компоненте: URL уже
|
||||
// является единственным источником правды для ownTab/ownStatus (см.
|
||||
// buildQuery — он не пишет значения по умолчанию в адрес), поэтому именно тут,
|
||||
// где URL молчит, естественно спросить «а что было в прошлый раз».
|
||||
// Пишутся ТОЛЬКО через persistOwnSelection — то есть только там, где
|
||||
// пользователь СОЗНАТЕЛЬНО меняет вкладку/фильтр (см. gotoOwnTab/setOwnStatus
|
||||
// в RequestPortal.tsx), а не при каждой навигации: иначе, например, просто
|
||||
// открыв и закрыв заявку, можно было бы затереть запомненное значение
|
||||
// значением по умолчанию.
|
||||
const OWN_TAB_STORAGE_KEY = 'rp-own-tab';
|
||||
const OWN_STATUS_STORAGE_KEY = 'rp-own-status';
|
||||
|
||||
function readStoredOwnTab(): OwnTab {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(OWN_TAB_STORAGE_KEY);
|
||||
return raw === 'approved' || raw === 'rejected' || raw === 'executed' ? raw : 'created';
|
||||
} catch {
|
||||
return 'created';
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredOwnStatus(): string | undefined {
|
||||
try {
|
||||
return window.localStorage.getItem(OWN_STATUS_STORAGE_KEY) || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Запомнить подвкладку/фильтр «Моих заявок» — вызывать только при явном выборе пользователя. */
|
||||
export function persistOwnSelection(ownTab: OwnTab, ownStatus: string | undefined): void {
|
||||
try {
|
||||
window.localStorage.setItem(OWN_TAB_STORAGE_KEY, ownTab);
|
||||
if (ownStatus) {
|
||||
window.localStorage.setItem(OWN_STATUS_STORAGE_KEY, ownStatus);
|
||||
} else {
|
||||
window.localStorage.removeItem(OWN_STATUS_STORAGE_KEY);
|
||||
}
|
||||
} catch {
|
||||
// localStorage недоступен (приватный режим и т.п.) — просто не сохраняем.
|
||||
}
|
||||
}
|
||||
|
||||
function parseNav(raw: string | undefined): NavSection {
|
||||
if (
|
||||
raw === 'approvals' ||
|
||||
@@ -76,8 +124,8 @@ function parseNav(raw: string | undefined): NavSection {
|
||||
}
|
||||
|
||||
function parseOwnTab(raw: string | undefined): OwnTab {
|
||||
if (raw === 'approved' || raw === 'rejected' || raw === 'executed') return raw;
|
||||
return 'created';
|
||||
if (raw === 'approved' || raw === 'rejected' || raw === 'executed' || raw === 'created') return raw;
|
||||
return readStoredOwnTab();
|
||||
}
|
||||
|
||||
/** Разобрать строку запроса (по умолчанию — из адреса страницы) в маршрут. */
|
||||
@@ -96,7 +144,7 @@ export function parseRoute(search: string = window.location.search): IRoute {
|
||||
// Фильтр по статусу живёт только на вкладке «Инициированные»: у вкладок с
|
||||
// решениями отбор задаёт сама вкладка.
|
||||
const ownTab = nav === 'own' ? parseOwnTab(params.get('rptab') ?? undefined) : undefined;
|
||||
const ownStatus = ownTab === 'created' ? params.get('rpstatus') ?? undefined : undefined;
|
||||
const ownStatus = ownTab === 'created' ? params.get('rpstatus') ?? readStoredOwnStatus() : undefined;
|
||||
|
||||
// Открытая заявка (панель поверх раздела) — при полном наборе параметров.
|
||||
// Раздел под панелью сохраняется целиком, вместе с подвкладкой и фильтром:
|
||||
|
||||
Reference in New Issue
Block a user