куча доработок

This commit is contained in:
gorbenko.pavel
2026-08-10 16:07:58 +05:00
parent 4cfb9a545d
commit 71335b325e
10 changed files with 277 additions and 190 deletions
@@ -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