10 Commits

Author SHA1 Message Date
gorbenko.pavel 742106c9f5 деплой
Tests / frontend-test (pull_request) Successful in 1m14s
2026-08-10 18:24:03 +05:00
gorbenko.pavel 67c8657618 у поиска нормальный роутинг 2026-08-10 18:18:42 +05:00
gorbenko.pavel 7ef0ee6245 добавлен комментарий 2026-08-10 17:52:25 +05:00
gorbenko.pavel 926a514821 добавлен комментарий 2026-08-10 17:44:32 +05:00
gorbenko.pavel 3dd4195539 всякие статусы лишние убраны 2026-08-10 17:36:55 +05:00
gorbenko.pavel b4b40ce235 все проблемы с кнопочками вроде ушли 2026-08-10 17:17:40 +05:00
gorbenko.pavel a80dc17c3e роутинг пагинации 2026-08-10 17:12:23 +05:00
gorbenko.pavel 36ee7ec009 проблема при нажатии кнопки 2026-08-10 17:00:06 +05:00
gorbenko.pavel d2e16167c7 пофикшен персист 2026-08-10 16:49:23 +05:00
gorbenko.pavel 71335b325e куча доработок 2026-08-10 16:07:58 +05:00
17 changed files with 1426 additions and 304 deletions
+105
View File
@@ -0,0 +1,105 @@
name: Build & Deploy to SharePoint Online
# Скопировано из tnsu-approval-route (.gitea/workflows/deploy_frontend.yml).
# Отличия — только в путях и идентификаторах: там SPFx-проект лежит в подпапке
# frontend/, здесь он и есть корень репозитория, поэтому working-directory и
# префиксы frontend/ убраны.
#
# Запуск только вручную (workflow_dispatch): деплой идёт сразу в каталог
# приложений тенанта, то есть на прод для всех сайтов.
on:
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js 22
uses: actions/setup-node@v3
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install dependencies
run: |
npm config set fetch-retries 5
npm config set fetch-retry-mintimeout 10000
npm config set fetch-retry-maxtimeout 120000
for i in 1 2 3; do
npm install --legacy-peer-deps && break
echo "Attempt $i failed, retrying in 20s..."
sleep 20
done
# npm run build = heft test --production (сборка + lint + Jest) и следом
# heft package-solution --production, который и создаёт .sppkg.
- name: Build & package SPFx solution
run: npm run build
- name: Upload .sppkg artifact
uses: actions/upload-artifact@v3
with:
name: request-portal-sppkg
path: sharepoint/solution/request-portal.sppkg
retention-days: 7
- name: Install CLI for Microsoft 365
run: |
for i in 1 2 3; do
npm install -g @pnp/cli-microsoft365@9 && break
echo "Attempt $i failed, retrying in 20s..."
sleep 20
done
- name: Login to Microsoft 365 (certificate)
run: |
m365 login \
--authType certificate \
--appId "${{ secrets.SPO_CLIENT_ID }}" \
--certificateBase64Encoded "${{ secrets.SPO_CERT_BASE64 }}" \
--thumbprint "${{ secrets.SPO_CERT_THUMBPRINT }}" \
--tenant "${{ secrets.SPO_TENANT_ID }}"
# SOLUTION_ID — solution.id из config/package-solution.json; при его смене
# правится и здесь, иначе deploy уйдёт в чужое приложение.
- name: Upload & Deploy to App Catalog
env:
SOLUTION_ID: "8c31266a-712c-4c72-ae04-bca5a6a071c3"
CATALOG_SITE: "https://tnsukz.sharepoint.com/sites/appcatalog"
SPPKG: "sharepoint/solution/request-portal.sppkg"
FILE_NAME: "request-portal.sppkg"
run: |
# Получаем токен через уже аутентифицированный m365 CLI
TOKEN=$(m365 util accesstoken get --resource https://tnsukz.sharepoint.com --output text)
echo "Token length: ${#TOKEN}"
echo ">>> Uploading to App Catalog"
HTTP=$(curl -s -o /tmp/upload.json -w "%{http_code}" -X POST \
"${CATALOG_SITE}/_api/web/GetFolderByServerRelativeUrl('/sites/appcatalog/AppCatalog')/Files/add(overwrite=true,url='${FILE_NAME}')" \
-H "Authorization: Bearer ${TOKEN}" \
-H "X-RequestDigest: 0x0" \
-H "Accept: application/json;odata=nometadata" \
-H "Content-Type: application/octet-stream" \
--data-binary @"${SPPKG}" || echo "000")
echo "Upload HTTP: ${HTTP}"
cat /tmp/upload.json
[ "$HTTP" = "200" ] || [ "$HTTP" = "201" ] || exit 1
echo ">>> Deploying app (id: ${SOLUTION_ID})"
HTTP=$(curl -s -o /tmp/deploy.json -w "%{http_code}" -X POST \
"${CATALOG_SITE}/_api/web/tenantappcatalog/AvailableApps/GetById('${SOLUTION_ID}')/deploy" \
-H "Authorization: Bearer ${TOKEN}" \
-H "X-RequestDigest: 0x0" \
-H "Accept: application/json;odata=nometadata" \
-H "Content-Type: application/json" \
-d '{"skipFeatureDeployment":true}' || echo "000")
echo "Deploy HTTP: ${HTTP}"
cat /tmp/deploy.json
[ "$HTTP" = "200" ] || [ "$HTTP" = "204" ] || exit 1
echo ">>> Done"
@@ -2,7 +2,20 @@ import * as React from 'react';
import styles from './RequestPortal.module.scss'; import styles from './RequestPortal.module.scss';
import type { IRequestPortalProps } from './IRequestPortalProps'; import type { IRequestPortalProps } from './IRequestPortalProps';
import { IconSprite } from '../shared/ui/Icon/IconSprite'; import { IconSprite } from '../shared/ui/Icon/IconSprite';
import { navigate, useRoute, NavSection, OwnTab } from '../shared/routing'; import {
navigate,
persistOwnTab,
persistOwnStatus,
persistPage,
persistQuery,
storedOwnStatus,
storedOwnTab,
storedPageFor,
storedQueryFor,
useRoute,
NavSection,
OwnTab,
} from '../shared/routing';
import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents'; import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents';
import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges'; import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges';
import { SideNav } from './SideNav/SideNav'; import { SideNav } from './SideNav/SideNav';
@@ -110,28 +123,89 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
// таблица на него подписана и перечитает данные фоном, не убирая с экрана // таблица на него подписана и перечитает данные фоном, не убирая с экрана
// текущие строки (см. RequestsTableView/SearchView). // текущие строки (см. RequestsTableView/SearchView).
if (nav === route.nav && route.mode === 'submit') emitRequestsChanged(); if (nav === route.nav && route.mode === 'submit') emitRequestsChanged();
// Возврат в «Мои заявки» открывает ту же подвкладку и с тем же фильтром, // Всё восстанавливаемое состояние раздела передаём ЯВНО, из запомненного.
// что были в прошлый раз, — иначе уход в другой раздел и обратно сбрасывал // parseRoute подставил бы его и сам, но тогда оно осталось бы только в
// бы выбор пользователя. // состоянии и не попало бы в адрес: ссылка на раздел вела бы на первую
navigate( // страницу без фильтра, а на экране была бы открыта другая.
nav === 'own' //
? { mode: 'submit', nav, ownTab: route.ownTab, ownStatus: route.ownStatus } // Брать route.ownTab/route.ownStatus здесь нельзя: при переходе ИЗ другого
: { mode: 'submit', nav } // раздела они undefined (parseRoute заполняет их только при nav==='own'),
); // и явная передача сбросила бы выбор пользователя.
if (nav === 'own') {
const ownTab = storedOwnTab();
navigate({
mode: 'submit',
nav,
ownTab,
ownStatus: storedOwnStatus(ownTab),
page: storedPageFor('own', ownTab),
query: storedQueryFor('own', ownTab) || undefined,
});
return;
}
navigate({
mode: 'submit',
nav,
page: storedPageFor(nav),
query: storedQueryFor(nav) || undefined,
});
}; };
const gotoOwnTab = (ownTab: OwnTab): void => const gotoOwnTab = (ownTab: OwnTab): void => {
// Фильтр по статусу относится только к «Инициированным» — на других // Фильтр и страница — из запомненного для ЭТОЙ подвкладки (см. goto выше о
// подвкладках отбор задаёт сама вкладка, поэтому фильтр туда не тащим. // том, почему не из route). На вкладках с решениями фильтра нет по смыслу —
// storedOwnStatus сам вернёт undefined.
persistOwnTab(ownTab);
navigate({ navigate({
mode: 'submit', mode: 'submit',
nav: 'own', nav: 'own',
ownTab, ownTab,
ownStatus: ownTab === 'created' ? route.ownStatus : undefined, ownStatus: storedOwnStatus(ownTab),
page: storedPageFor('own', ownTab),
query: storedQueryFor('own', ownTab) || undefined,
}); });
};
const setOwnStatus = (ownStatus: string): void => const setOwnStatus = (ownStatus: string): void => {
navigate({ mode: 'submit', nav: 'own', ownTab: 'created', ownStatus: ownStatus || undefined }); const normalized = ownStatus || undefined;
persistOwnStatus(normalized);
// Смена фильтра — другой набор заявок, поэтому явно с первой страницы
// (и запоминаем именно её, иначе вернувшись сюда попали бы на старую).
persistPage({ mode: 'submit', nav: 'own', ownTab: 'created' }, 1);
// Текст поиска переносим ЯВНО: раздел тот же, поле на экране не очищается —
// значит и в адресе запрос должен остаться. Без этого rpq пропадал из
// ссылки при каждой смене фильтра (в самом состоянии он всё равно
// восстанавливался из запомненного, поэтому расхождение адреса с экраном
// было видно не сразу).
navigate({
mode: 'submit',
nav: 'own',
ownTab: 'created',
ownStatus: normalized,
page: 1,
query: route.query || undefined,
});
};
// Смена страницы сохраняет ВЕСЬ остальной маршрут (в т.ч. открытую заявку),
// меняется только номер: так «назад» возвращает на прежнюю страницу, а
// ссылку со страницей можно передать.
const setPage = (page: number): void => {
persistPage(route, page);
navigate({ ...route, page });
};
// Новый запрос — другой набор результатов, поэтому вместе с ним всегда первая
// страница (иначе можно приземлиться на несуществующую).
//
// replace, а не push: текст уходит сюда после КАЖДОЙ паузы в наборе, и с
// обычным переходом каждое слово становилось бы отдельным шагом «назад» —
// чтобы уйти со страницы, пришлось бы «отлистать» весь набранный запрос.
const setQuery = (query: string): void => {
persistQuery(route, query);
persistPage(route, 1);
navigate({ ...route, query: query || undefined, page: 1 }, { replace: true });
};
// Клик по строке таблицы. СВОЯ заявка в незавершённом статусе (черновик, // Клик по строке таблицы. СВОЯ заявка в незавершённом статусе (черновик,
// возвращённая, отклонённая) открывается на редактирование, всё остальное — // возвращённая, отклонённая) открывается на редактирование, всё остальное —
// на просмотр: чужую заявку править нельзя, а свою уже ушедшую по маршруту — // на просмотр: чужую заявку править нельзя, а свою уже ушедшую по маршруту —
@@ -144,12 +218,23 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
itemId: request.id, itemId: request.id,
ownTab: route.ownTab, ownTab: route.ownTab,
ownStatus: route.ownStatus, ownStatus: route.ownStatus,
// Страница и запрос — часть того же списка под панелью: без них ссылка на
// открытую заявку вела бы на первую страницу без поиска.
page: route.page,
query: route.query,
}); });
// Закрывая панель, возвращаемся ровно в тот список, из которого её открыли — // Закрывая панель, возвращаемся ровно в тот список, из которого её открыли —
// включая подвкладку и фильтр «Моих заявок». // включая подвкладку и фильтр «Моих заявок».
const closeDrawer = (): void => const closeDrawer = (): void =>
navigate({ mode: 'submit', nav: route.nav, ownTab: route.ownTab, ownStatus: route.ownStatus }); navigate({
mode: 'submit',
nav: route.nav,
ownTab: route.ownTab,
ownStatus: route.ownStatus,
page: route.page,
query: route.query,
});
// Закрытие панели РЕДАКТИРОВАНИЯ: там открыта форма, и её содержимое живёт // Закрытие панели РЕДАКТИРОВАНИЯ: там открыта форма, и её содержимое живёт
// только в памяти — уходя без сохранения, пользователь теряет правки. // только в памяти — уходя без сохранения, пользователь теряет правки.
@@ -220,6 +305,10 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
{route.nav === 'search' && ( {route.nav === 'search' && (
<SearchView <SearchView
context={context} context={context}
page={route.page ?? 1}
onPageChange={setPage}
query={route.query ?? ''}
onQueryChange={setQuery}
onOpen={openRequest} onOpen={openRequest}
/> />
)} )}
@@ -238,6 +327,10 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
} }
statusFilter={route.ownStatus ?? ''} statusFilter={route.ownStatus ?? ''}
onStatusFilterChange={setOwnStatus} onStatusFilterChange={setOwnStatus}
page={route.page ?? 1}
onPageChange={setPage}
query={route.query ?? ''}
onQueryChange={setQuery}
onOpen={openRequest} onOpen={openRequest}
/> />
)} )}
@@ -66,6 +66,17 @@
margin-left: 0; 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 { .search {
width: 280px; width: 280px;
max-width: 100%; max-width: 100%;
@@ -187,41 +198,19 @@
width: 190px; width: 190px;
} }
.status { // Причина возврата — свободный текст согласующего, поэтому переносим по словам
display: inline-block; // (в т.ч. длинное слово без пробелов, иначе оно растянет таблицу).
padding: 3px 10px; .colComment {
border-radius: $r-md; white-space: normal;
font-size: $fs-caption; overflow-wrap: anywhere;
font-weight: 500;
line-height: 1.3;
}
.statusDraft {
background: $gray-100;
color: $gray-700; color: $gray-700;
} }
.statusOnApproval { .commentEmpty {
background: $gold-100; color: $gray-400;
color: $gold-600;
} }
.statusApproved { // Раскраска статуса — теперь только в shared/approval/ui/StatusBadge (см.
background: $green-50; // использование ниже с size="sm"). Раньше здесь была своя копия этих цветов —
color: $green-600; // она разошлась с эталоном (На согласовании/Возвращена на доработку оказались
} // перепутаны), поэтому копию убрали, а не подправили.
.statusRejected {
background: $red-50;
color: $red-500;
}
.statusReturned {
background: $blue-50;
color: $blue-600;
}
.statusDefault {
background: $gray-100;
color: $gray-500;
}
@@ -7,13 +7,15 @@ import {
getApprovalQueueIndex, getApprovalQueueIndex,
getLinkedTypes, getLinkedTypes,
getOwnRequests, getOwnRequests,
getOwnRequestsByStatus,
hydrateDecisionComments,
hydratePendingAuthors, hydratePendingAuthors,
searchOwnRequests, searchOwnRequests,
} from './myRequests.service'; } from './myRequests.service';
import { IMyRequest } from './models/IMyRequest'; 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 { onRequestsChanged } from '../../shared/requestEvents';
import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery'; import { useRoutedQuery } from '../../shared/usePersistedQuery';
import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache'; import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
import { Pager } from './Pager'; import { Pager } from './Pager';
import styles from './RequestsTableView.module.scss'; import styles from './RequestsTableView.module.scss';
@@ -76,13 +78,84 @@ const EMPTY_TEXTS: Record<ListView, string> = {
'own:executed': 'Вы ещё ничего не выполнили.', '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;
}
}
// Колонки: «Автор» нужен там, где заявка чужая; «Статус» — где он не следует из // Колонки: «Автор» нужен там, где заявка чужая; «Статус» — где он не следует из
// самого раздела. В истории решений важно и то, и другое: заявка чужая, а её // самого раздела. В истории решений важно и то, и другое: заявка чужая, а её
// текущий статус мог уйти дальше вашего решения. // текущий статус мог уйти дальше вашего решения.
function columnsFor(view: ListView): { author: boolean; status: boolean } { //
// '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 === 'approvals' || view === 'execution') return { author: true, status: false };
if (view === 'rework' || view === 'own:created') return { author: false, status: true }; if (view === 'rework') return { author: false, status: false, commentLabel };
return { author: true, status: true }; if (view === 'own:created') return { author: false, status: !statusFilter, commentLabel };
// История решений: заявка чужая, поэтому нужен автор. Статус — только если он
// не предопределён самой вкладкой («Отклонённые мной» — это всегда «Отклонено»,
// а вот у согласованных/выполненных заявка могла уйти дальше по маршруту).
return { author: true, status: !decisionStatus, commentLabel };
} }
export interface IRequestsTableViewProps { export interface IRequestsTableViewProps {
@@ -91,6 +164,20 @@ export interface IRequestsTableViewProps {
/** Фильтр по статусу заявки; только для view='own:created'. '' — все статусы. */ /** Фильтр по статусу заявки; только для view='own:created'. '' — все статусы. */
statusFilter?: string; statusFilter?: string;
onStatusFilterChange?: (status: string) => void; 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; onOpen: (request: IMyRequest) => void;
} }
@@ -103,36 +190,18 @@ function formatDate(iso: string): string {
return `${dd}.${mm}.${d.getFullYear()}`; 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 // строка, а не только название), поэтому строка — <tr> с onClick + tabIndex
// (доступ с клавиатуры: Enter/Space). // (доступ с клавиатуры: Enter/Space).
// //
// «approvals»: автор — не текущий пользователь, поэтому нужна колонка «Автор»; // Набор колонок зависит от раздела и от фильтра — см. columnsFor.
// статус не показываем — здесь все заявки по определению на согласовании.
// «own»: автор всегда текущий пользователь, зато важен статус.
const Table: React.FC<{ const Table: React.FC<{
rows: IMyRequest[]; rows: IMyRequest[];
view: ListView; view: ListView;
statusFilter: string;
onOpen: (request: IMyRequest) => void; onOpen: (request: IMyRequest) => void;
}> = ({ rows, view, onOpen }) => { }> = ({ rows, view, statusFilter, onOpen }) => {
const columns = columnsFor(view); const columns = columnsFor(view, statusFilter);
return ( return (
<div className={styles.tableWrap}> <div className={styles.tableWrap}>
<table className={styles.table}> <table className={styles.table}>
@@ -144,6 +213,7 @@ const Table: React.FC<{
<th className={styles.colDate}>Дата</th> <th className={styles.colDate}>Дата</th>
{columns.author && <th className={styles.colAuthor}>Автор</th>} {columns.author && <th className={styles.colAuthor}>Автор</th>}
{columns.status && <th className={styles.colStatus}>Статус</th>} {columns.status && <th className={styles.colStatus}>Статус</th>}
{columns.commentLabel && <th className={styles.colComment}>{columns.commentLabel}</th>}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -173,7 +243,12 @@ const Table: React.FC<{
)} )}
{columns.status && ( {columns.status && (
<td className={styles.colStatus}> <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> </td>
)} )}
</tr> </tr>
@@ -205,9 +280,12 @@ const Table: React.FC<{
// после фильтрации — нарезка на страницы и дочитывание автора // после фильтрации — нарезка на страницы и дочитывание автора
// (hydratePendingAuthors) — тоже только для видимой страницы. // (hydratePendingAuthors) — тоже только для видимой страницы.
// //
// Отбор по статусу ЗАЯВКИ ('rework' и фильтр на 'own:created') уходит в тот же // Отбор по статусу ЗАЯВКИ ('rework' и фильтр на 'own:created') — НЕ через KQL:
// KQL отдельным условием (см. MP_TICKET_STATUS) — то есть работает вместе с // managed property для произвольной Choice-колонки ненадёжна (см.
// текстовым поиском и с серверной постраничностью, без вычитывания архива. // getOwnRequestsByStatus в myRequests.service.ts — там объяснение и ссылка на
// реальный инцидент). Вместо этого Search ищет только по автору (надёжная
// AuthorOWSUSER), а статус фильтруется в памяти по полям, дочитанным через
// REST; постраничность там же, в памяти.
// //
// Все варианты перечитываются по событию rp-requests-changed // Все варианты перечитываются по событию rp-requests-changed
// (shared/requestEvents) — после создания заявки и после решения согласующего. // (shared/requestEvents) — после создания заявки и после решения согласующего.
@@ -217,20 +295,35 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
view, view,
statusFilter = '', statusFilter = '',
onStatusFilterChange, onStatusFilterChange,
page,
onPageChange,
query,
onQueryChange,
onOpen, onOpen,
}) => { }) => {
const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined); const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined); const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
const [totalRows, setTotalRows] = React.useState<number>(0); const [totalRows, setTotalRows] = React.useState<number>(0);
const [page, setPage] = React.useState<number>(1);
const [query, setQuery] = usePersistedQuery(view);
const [error, setError] = React.useState<string>(''); const [error, setError] = React.useState<string>('');
// query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что
// уходит в запрос (после паузы в наборе). Всё ниже завязано на debouncedQuery,
// иначе поиск бил бы запросом по каждой набранной букве.
const debouncedQuery = useDebouncedQuery(query);
React.useEffect(() => setPage(1), [debouncedQuery, view, statusFilter]); // 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(() => { React.useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -259,17 +352,24 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
const cached = readPage(cacheKey); const cached = readPage(cacheKey);
setRows(cached ? cached.rows : undefined); setRows(cached ? cached.rows : undefined);
setTotalRows(cached ? cached.totalRows : 0); setTotalRows(cached ? cached.totalRows : 0);
// Пока не подтвердим, что загруженное относится к ТЕКУЩЕЙ таблице, ничего
// на основании totalRows решать нельзя — см. loadedKey ниже.
setLoadedKey(cached ? dataKey : '');
const q = debouncedQuery.trim(); const q = debouncedQuery.trim();
const fetchPage = async (): Promise<IPage> => { const fetchRows = async (): Promise<IPage> => {
// Свои заявки: «Доработка» — тот же список, что «Инициированные», просто // «Доработка» — тот же список, что «Инициированные», просто со статусом,
// со статусом, зашитым в раздел, а не выбранным в фильтре. // зашитым в раздел, а не выбранным в фильтре.
if (view === 'rework' || view === 'own:created') { if (view === 'rework' || (view === 'own:created' && statusFilter)) {
const status = view === 'rework' ? REQUEST_STATUS.returned : statusFilter; const status = view === 'rework' ? REQUEST_STATUS.returned : statusFilter;
return getOwnRequestsByStatus(context, types, status, page, debouncedQuery);
}
if (view === 'own:created') {
return q.length > 0 return q.length > 0
? searchOwnRequests(context, types, debouncedQuery, page, status) ? searchOwnRequests(context, types, debouncedQuery, page)
: getOwnRequests(context, types, page, status); : getOwnRequests(context, types, page);
} }
const queue = QUEUE_BY_VIEW[view]; const queue = QUEUE_BY_VIEW[view];
@@ -289,6 +389,19 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
return { rows: pageRows, totalRows: filtered.length }; 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> => { const load = async (): Promise<void> => {
try { try {
const fresh = await fetchPage(); const fresh = await fetchPage();
@@ -303,6 +416,9 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
setRows(fresh.rows); setRows(fresh.rows);
setTotalRows(fresh.totalRows); setTotalRows(fresh.totalRows);
} }
// Данные точно от текущей таблицы — теперь по ним можно судить о
// количестве страниц (см. сброс страницы ниже).
setLoadedKey(dataKey);
setError(''); setError('');
} catch (e) { } catch (e) {
if (!cancelled) setError((e as Error).message); if (!cancelled) setError((e as Error).message);
@@ -331,6 +447,19 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
const visible = rows || []; const visible = rows || [];
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
// Страница может оказаться за пределами набора: пришла из старой ссылки или
// из запомненной, а заявок с тех пор стало меньше (часть согласовали, и они
// ушли из этой очереди). Показывать пустую таблицу с непустым счётчиком —
// выглядит как поломка, поэтому возвращаемся на последнюю существующую.
//
// Строго ПОСЛЕ проверки loadedKey: totalRows в состоянии может быть ещё от
// предыдущей таблицы (компонент общий), и решение по нему сбило бы страницу
// на первую при обычном переходе по левому меню.
React.useEffect(() => {
if (loadedKey !== dataKey) return;
if (rows && page > totalPages) onPageChange(totalPages);
}, [loadedKey, dataKey, rows, page, totalPages, onPageChange]);
return ( return (
<section className={styles.view}> <section className={styles.view}>
<div className={styles.head}> <div className={styles.head}>
@@ -340,14 +469,17 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
</h2> </h2>
{view === 'own:created' && onStatusFilterChange && ( {view === 'own:created' && onStatusFilterChange && (
<select <select
className={styles.statusFilter} // Цвет самого <select> (закрытое состояние) — тем же классом, что и
// у выбранного <option>: statusFilter задаёт нейтральные border/фон,
// а конкретный statusOptionClassName(statusFilter) перекрывает color.
className={`${styles.statusFilter} ${statusFilter ? statusOptionClassName(statusFilter) : ''}`}
value={statusFilter} value={statusFilter}
onChange={(e) => onStatusFilterChange(e.target.value)} onChange={(e) => onStatusFilterChange(e.target.value)}
aria-label="Фильтр по статусу" aria-label="Фильтр по статусу"
> >
<option value="">Все статусы</option> <option value="">Все статусы</option>
{REQUEST_STATUS_CHOICES.map((s) => ( {REQUEST_STATUS_CHOICES.map((s) => (
<option key={s} value={s}> <option key={s} value={s} className={statusOptionClassName(s)}>
{s} {s}
</option> </option>
))} ))}
@@ -357,8 +489,8 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
type="search" type="search"
className={styles.search} className={styles.search}
placeholder="Поиск по заявкам…" placeholder="Поиск по заявкам…"
value={query} value={input}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setInput(e.target.value)}
/> />
</div> </div>
@@ -371,8 +503,8 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
)} )}
{!error && rows && total > 0 && ( {!error && rows && total > 0 && (
<> <>
<Table rows={visible} view={view} onOpen={onOpen} /> <Table rows={visible} view={view} statusFilter={statusFilter} onOpen={onOpen} />
<Pager page={page} totalPages={totalPages} onChange={setPage} /> <Pager page={page} totalPages={totalPages} onChange={onPageChange} />
</> </>
)} )}
</section> </section>
@@ -163,41 +163,7 @@
white-space: nowrap; white-space: nowrap;
} }
.status { // Раскраска статуса — теперь только в shared/approval/ui/StatusBadge (см.
display: inline-block; // использование ниже с size="sm"). Раньше здесь была своя копия этих цветов —
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;
}
@@ -1,10 +1,10 @@
import * as React from 'react'; import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; 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 { IMyRequest } from './models/IMyRequest';
import { REQUEST_STATUS, IRequestType } from '../../shared/approval'; import { IRequestType, StatusBadge } from '../../shared/approval';
import { onRequestsChanged } from '../../shared/requestEvents'; import { onRequestsChanged } from '../../shared/requestEvents';
import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery'; import { useRoutedQuery } from '../../shared/usePersistedQuery';
import { pageKey, pageSignature, readPage, writePage } from './pageCache'; import { pageKey, pageSignature, readPage, writePage } from './pageCache';
import { Pager } from './Pager'; import { Pager } from './Pager';
import styles from './SearchView.module.scss'; import styles from './SearchView.module.scss';
@@ -20,10 +20,35 @@ const AUTO_REFRESH_MS = 10 * 60 * 1000;
export interface ISearchViewProps { export interface ISearchViewProps {
context: WebPartContext; 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; 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 { function formatDate(iso: string): string {
if (!iso) return ''; if (!iso) return '';
const d = new Date(iso); const d = new Date(iso);
@@ -32,23 +57,6 @@ function formatDate(iso: string): string {
return `${dd}.${mm}.${d.getFullYear()}`; 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 (где по смыслу раздела нужно либо Автор, // В отличие от RequestsTableView (где по смыслу раздела нужно либо Автор,
// либо Статус) — здесь смешаны «свои» и «где участвовал» заявки, поэтому // либо Статус) — здесь смешаны «свои» и «где участвовал» заявки, поэтому
// показываем обе колонки сразу, плюс небольшая метка «Автор/Согласование» — // показываем обе колонки сразу, плюс небольшая метка «Автор/Согласование» —
@@ -91,7 +99,7 @@ const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => voi
{[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'} {[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'}
</td> </td>
<td className={styles.colStatus}> <td className={styles.colStatus}>
<span className={`${styles.status} ${statusClass(r.status)}`}>{r.status || '—'}</span> {r.status ? <StatusBadge status={r.status} size="sm" /> : '—'}
</td> </td>
<td className={styles.colKind}>{r.kind === 'own' ? 'Создал(а) я' : 'Участвую'}</td> <td className={styles.colKind}>{r.kind === 'own' ? 'Создал(а) я' : 'Участвую'}</td>
</tr> </tr>
@@ -111,20 +119,33 @@ const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => voi
// после решения согласующего) — но т.к. это по большей части Search, свежая // после решения согласующего) — но т.к. это по большей части Search, свежая
// правка может проявиться не мгновенно (частично компенсируется довеском // правка может проявиться не мгновенно (частично компенсируется довеском
// последних 10 минут через REST — см. myRequests.service.ts). // последних 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 [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined); const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
const [totalRows, setTotalRows] = React.useState<number>(0); const [totalRows, setTotalRows] = React.useState<number>(0);
const [page, setPage] = React.useState<number>(1);
const [query, setQuery] = usePersistedQuery('search');
// query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что
// уходит в запрос (после паузы в наборе). Здесь это особенно важно: поиск
// ходит сразу по двум источникам (свои заявки + где участвую), то есть
// каждая набранная буква стоила бы двух серий запросов.
const debouncedQuery = useDebouncedQuery(query);
const [error, setError] = React.useState<string>(''); 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(() => { React.useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -140,6 +161,11 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
}; };
}, [context]); }, [context]);
// Поиск зависит ТОЛЬКО от запроса, не от страницы: результат приходит целиком
// и кешируется целиком, а страницы нарезаются ниже, в рендере. Раньше страница
// входила в ключ кеша — и каждое листание было промахом, который заново гонял
// обе серии запросов (свои + где участвую) и показывал «Ищем…», хотя все эти
// строки уже были получены при загрузке первой страницы.
React.useEffect(() => { React.useEffect(() => {
if (types === undefined) return; if (types === undefined) return;
if (debouncedQuery.trim().length === 0) { if (debouncedQuery.trim().length === 0) {
@@ -148,7 +174,7 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
return; return;
} }
let cancelled = false; let cancelled = false;
const cacheKey = pageKey('search', [debouncedQuery.trim(), page]); const cacheKey = pageKey('search', [debouncedQuery.trim()]);
// Уже искали это раньше — показываем прежний результат сразу, обновление // Уже искали это раньше — показываем прежний результат сразу, обновление
// идёт фоном (см. RequestsTableView, там та же схема и те же причины). // идёт фоном (см. RequestsTableView, там та же схема и те же причины).
@@ -158,8 +184,9 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
const load = async (): Promise<void> => { const load = async (): Promise<void> => {
try { try {
const fresh = await searchMyRequests(context, types, debouncedQuery, page); const fresh = await searchMyRequests(context, types, debouncedQuery);
if (cancelled) return; if (cancelled) return;
applyKnownAuthors(fresh.rows, authors.current);
const prev = readPage(cacheKey); const prev = readPage(cacheKey);
if (!prev || pageSignature(prev) !== pageSignature(fresh)) { if (!prev || pageSignature(prev) !== pageSignature(fresh)) {
writePage(cacheKey, fresh); writePage(cacheKey, fresh);
@@ -184,10 +211,39 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
unsubscribe(); unsubscribe();
window.clearInterval(timer); window.clearInterval(timer);
}; };
}, [context, types, debouncedQuery, page]); }, [context, types, debouncedQuery]);
const hasQuery = debouncedQuery.trim().length > 0; const hasQuery = debouncedQuery.trim().length > 0;
const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE)); 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 ( return (
<section className={styles.view}> <section className={styles.view}>
@@ -200,8 +256,8 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
type="search" type="search"
className={styles.search} className={styles.search}
placeholder="Введите текст — ищем по своим заявкам и по тем, где вы участвуете…" placeholder="Введите текст — ищем по своим заявкам и по тем, где вы участвуете…"
value={query} value={input}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setInput(e.target.value)}
autoFocus autoFocus
/> />
</div> </div>
@@ -214,8 +270,8 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
)} )}
{!error && hasQuery && rows && totalRows > 0 && ( {!error && hasQuery && rows && totalRows > 0 && (
<> <>
<Table rows={rows} onOpen={onOpen} /> <Table rows={visible ?? []} onOpen={onOpen} />
<Pager page={page} totalPages={totalPages} onChange={setPage} /> <Pager page={page} totalPages={totalPages} onChange={onPageChange} />
</> </>
)} )}
</section> </section>
@@ -22,4 +22,11 @@ export interface IMyRequest {
* не показывается — поэтому и не входит в pageSignature. * не показывается — поэтому и не входит в pageSignature.
*/ */
myStepStatus?: string; myStepStatus?: string;
/**
* Комментарий согласующего к решению — причина возврата на доработку или
* отклонения (какое именно решение, задаёт вызывающая сторона). Живёт не в
* списке данных, а в строке МАРШРУТА, поэтому дочитывается отдельно и только
* там, где показывается (см. hydrateDecisionComments).
*/
decisionComment?: string;
} }
@@ -41,20 +41,18 @@ const MP_APPROVER = 'TansuSiteApproverOWSUSER';
// Статус СТРОКИ МАРШРУТА (колонка ApprovalStatus списка «— согласование») — // Статус СТРОКИ МАРШРУТА (колонка ApprovalStatus списка «— согласование») —
// Choice-поле, отсюда суффикс OWSCHCS (подтверждено отладочным перебором // Choice-поле, отсюда суффикс OWSCHCS (подтверждено отладочным перебором
// 2026-08-06; у Person-полей он OWSUSER, см. MP_APPROVER). Колонку не путать с // 2026-08-06; у Person-полей он OWSUSER, см. MP_APPROVER). Колонку не путать с
// RequestStatus списка ДАННЫХ — это статус ЗАЯВКИ целиком, у него своя // RequestStatus списка ДАННЫХ — это статус ЗАЯВКИ целиком.
// managed property (см. MP_TICKET_STATUS ниже). //
// Для статуса ЗАЯВКИ (список ДАННЫХ) отдельной managed property здесь
// НАМЕРЕННО нет: имя для произвольной Choice-колонки в схеме поиска либо надо
// знать точно, либо угадывать по конвенции — угадали неверно (или колонка
// после переименования Status → RequestStatus ещё не переиндексирована), и
// фильтр по статусу молча возвращал 0 заявок. Поэтому статус заявки
// фильтруется НЕ в KQL, а в памяти после дочитывания реальных полей через REST
// (см. getOwnRequestsByStatus) — Search там ищет только по надёжному
// AuthorOWSUSER, статус ни при чём.
const MP_STATUS = 'ApprovalStatusOWSCHCS'; 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 { export function currentUserEmail(context: WebPartContext): string {
return (context.pageContext.user.email || '').toLowerCase(); return (context.pageContext.user.email || '').toLowerCase();
} }
@@ -210,7 +208,7 @@ export async function getNavCounts(context: WebPartContext): Promise<INavCounts>
const [turn, rework] = await Promise.all([ const [turn, rework] = await Promise.all([
buildPendingIndex(context, 'myTurn', MY_TURN_STATUSES), buildPendingIndex(context, 'myTurn', MY_TURN_STATUSES),
getOwnRequests(context, types, 1, REQUEST_STATUS.returned), getOwnRequestsByStatus(context, types, REQUEST_STATUS.returned, 1),
]); ]);
return { return {
@@ -593,8 +591,7 @@ async function loadByIds(
// Search — сырьё для довеска по свежести (см. getFreshOwnRowsForType). // Search — сырьё для довеска по свежести (см. getFreshOwnRowsForType).
// Фильтруем только по AuthorId: статус в $filter не используем, потому что // Фильтруем только по AuthorId: статус в $filter не используем, потому что
// кириллица в сравнении там уже подводила (см. indexApprovalsForType) — отбор // кириллица в сравнении там уже подводила (см. indexApprovalsForType) — отбор
// по статусу делает вызывающая сторона. В самом Search кириллица проблемой не // по статусу делает вызывающая сторона.
// является, там статус уходит прямо в KQL (см. MP_TICKET_STATUS).
async function getOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> { async function getOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
try { try {
const sp = spForSite(type.siteUrl as string, context); 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 с серверной пагинацией // «Мои заявки» БЕЗ поиска — гибрид для масштаба: Search с серверной пагинацией
// (RowLimit/StartRow) находит страницу без сканирования всего архива, REST по // (RowLimit/StartRow) находит страницу без сканирования всего архива, REST по
// «последним N минутам» добирает то, что Search мог не успеть проиндексировать // «последним N минутам» добирает то, что Search мог не успеть проиндексировать
@@ -644,20 +674,18 @@ async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestTyp
export async function getOwnRequests( export async function getOwnRequests(
context: WebPartContext, context: WebPartContext,
types: IRequestType[], types: IRequestType[],
page: number, page: number
status: string = ''
): Promise<IOwnRequestsPage> { ): Promise<IOwnRequestsPage> {
if (types.length === 0) return { rows: [], totalRows: 0 }; 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; if (page !== 1) return searchPage;
// Довесок по свежести отбираем по статусу здесь: он приходит REST-ом, где const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t)))).reduce(
// сравнивать статус в $filter нельзя (кириллица), — зато набор маленький. (all, r) => all.concat(r),
const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t)))) [] as IMyRequest[]
.reduce((all, r) => all.concat(r), [] as IMyRequest[]) );
.filter((r) => !status || r.status === status);
const searchKeys = new Set(searchPage.rows.map((r) => `${r.requestTypeId}-${r.id}`)); const searchKeys = new Set(searchPage.rows.map((r) => `${r.requestTypeId}-${r.id}`));
const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${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, context: WebPartContext,
types: IRequestType[], types: IRequestType[],
query: string, query: string,
page: number, page: number
status: string = ''
): Promise<IOwnRequestsPage> { ): Promise<IOwnRequestsPage> {
if (types.length === 0) return { rows: [], totalRows: 0 }; if (types.length === 0) return { rows: [], totalRows: 0 };
@@ -689,9 +716,6 @@ export async function searchOwnRequests(
`(${types.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`, `(${types.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`,
`AuthorOWSUSER:"${claimsLogin}"`, `AuthorOWSUSER:"${claimsLogin}"`,
]; ];
// Отбор по статусу заявки — на СЕРВЕРЕ, вместе с постраничностью: иначе
// пришлось бы вычитывать весь архив автора и резать его в памяти.
if (status) clauses.push(`${MP_TICKET_STATUS}:"${status}"`);
const text = freeTextClause(query); const text = freeTextClause(query);
if (text) clauses.push(text); if (text) clauses.push(text);
const kql = clauses.join(' AND '); const kql = clauses.join(' AND ');
@@ -708,18 +732,6 @@ export async function searchOwnRequests(
const totalRows = results.TotalRows || 0; 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 entries = hitsToEntries(results, types);
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>(); const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
// Порядок задаёт релевантность поиска, а не порядок ответов REST. // Порядок задаёт релевантность поиска, а не порядок ответов REST.
@@ -728,6 +740,146 @@ export async function searchOwnRequests(
return { rows, totalRows }; 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
);
}
})
);
}
// «На согласовании»: список заявок уже отобран по строкам согласования // «На согласовании»: список заявок уже отобран по строкам согласования
// (getPendingApprovalIndex — там же определяется, чья сейчас очередь, // (getPendingApprovalIndex — там же определяется, чья сейчас очередь,
// обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search // обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search
@@ -791,7 +943,7 @@ export interface ISearchPage {
// Как searchOwnRequests, но без $skip/$top — сразу весь найденный срез (до // Как searchOwnRequests, но без $skip/$top — сразу весь найденный срез (до
// KQL_ID_LIMIT). Только Search, без REST-подстраховки на свежесть. // KQL_ID_LIMIT). Только Search, без REST-подстраховки на свежесть.
async function searchOwnAll( export async function searchOwnAll(
context: WebPartContext, context: WebPartContext,
types: IRequestType[], types: IRequestType[],
query: string query: string
@@ -821,11 +973,25 @@ async function searchOwnAll(
return entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r); 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( export async function searchMyRequests(
context: WebPartContext, context: WebPartContext,
types: IRequestType[], types: IRequestType[],
query: string, query: string
page: number
): Promise<ISearchPage> { ): Promise<ISearchPage> {
const q = query.trim(); const q = query.trim();
if (!q) return { rows: [], totalRows: 0 }; if (!q) return { rows: [], totalRows: 0 };
@@ -843,17 +1009,7 @@ export async function searchMyRequests(
const involvedOnly = involvedFiltered.filter((r) => !ownKeys.has(`${r.requestTypeId}-${r.id}`)); const involvedOnly = involvedFiltered.filter((r) => !ownKeys.has(`${r.requestTypeId}-${r.id}`));
const merged = ownRows.concat(involvedOnly).sort((a, b) => b.created.localeCompare(a.created)); const merged = ownRows.concat(involvedOnly).sort((a, b) => b.created.localeCompare(a.created));
const totalRows = merged.length; return { rows: merged, 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 };
} }
function toMyRequest( function toMyRequest(
@@ -99,6 +99,18 @@ describe('pageSignature', () => {
expect(pageSignature(before)).toBe(pageSignature(after)); 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('пустая страница имеет стабильную подпись', () => { it('пустая страница имеет стабильную подпись', () => {
expect(pageSignature({ rows: [], totalRows: 0 })).toBe(pageSignature({ rows: [], totalRows: 0 })); 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 { export function pageSignature(page: IPage): string {
return `${page.totalRows}|${page.rows 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(';')}`; .join(';')}`;
} }
@@ -40,7 +40,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
// подсвечивается пол-таблицы и непонятно, что именно переехало. Запоминаем // подсвечивается пол-таблицы и непонятно, что именно переехало. Запоминаем
// ключи блока ДО перестановки: после неё индексы уже другие. // ключи блока ДО перестановки: после неё индексы уже другие.
const [activeKeys, setActiveKeys] = React.useState<string[]>([]); const [activeKeys, setActiveKeys] = React.useState<string[]>([]);
const registerRow = useFlipRows(activeKeys); const registerRow = useFlipRows(activeKeys, rowKeys);
// Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку // Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку
// «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже. // «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже.
@@ -12,6 +12,16 @@
white-space: nowrap; 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 { .draft {
background: $gray-100; background: $gray-100;
color: $gray-700; color: $gray-700;
@@ -16,7 +16,25 @@ const VARIANT: Record<string, string> = {
[REQUEST_STATUS.closed]: styles.closed, [REQUEST_STATUS.closed]: styles.closed,
}; };
/** Цветная плашка статуса заявки (шапка формы / просмотра). */ export interface IStatusBadgeProps {
export const StatusBadge: React.FC<{ status: string }> = ({ status }) => ( status: string;
<span className={`${styles.badge} ${VARIANT[status] || styles.draft}`}>{status}</span> /**
* '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>
); );
@@ -67,19 +67,60 @@ function drop(row: HTMLTableRowElement): void {
}); });
} }
/**
* Положение строк ОТНОСИТЕЛЬНО их <tbody>, а не окна.
*
* Это принципиально: getBoundingClientRect() отдаёт координату относительно
* ОКНА, а прокрутка страницы не вызывает ре-рендер — значит, запомненные с
* прошлого рендера позиции остаются от прежнего положения прокрутки. Стоит
* пользователю прокрутить страницу к маршруту и нажать стрелку, как в сдвиг
* попадает вся дельта прокрутки (сотни пикселей), и строки улетают за пределы
* таблицы. Именно так выглядел баг «первое нажатие ломает, дальше нормально»:
* повторные клики уже сравнивались с позициями, замеренными при текущей
* прокрутке, поэтому шли правильно.
*
* Относительная база от этого избавляет: и строка, и <tbody> замеряются в
* одном кадре, поэтому прокрутка сокращается. Заодно это чинит и случай, когда
* блок маршрута целиком уезжает из-за изменений НАД ним (например, свернулся
* блок вложений): tbody уезжает вместе со строками, и мнимого сдвига нет.
*/
function measureTops(nodes: Map<string, HTMLTableRowElement>): Map<string, number> {
const tops = new Map<string, number>();
let firstRow: HTMLTableRowElement | undefined;
nodes.forEach((el) => {
if (!firstRow) firstRow = el;
});
if (!firstRow) return tops;
const container = (firstRow as HTMLTableRowElement).parentElement;
const base = container ? container.getBoundingClientRect().top : 0;
nodes.forEach((el, key) => tops.set(key, el.getBoundingClientRect().top - base));
return tops;
}
/** /**
* Возвращает функцию, которая по ключу строки отдаёт ref-колбэк для её <tr>. * Возвращает функцию, которая по ключу строки отдаёт ref-колбэк для её <tr>.
* @param activeKeys ключи строк того шага, который двигает пользователь — * @param activeKeys ключи строк того шага, который двигает пользователь —
* только он подсвечивается и поднимается над остальными (см. ApprovalMatrix). * только он подсвечивается и поднимается над остальными (см. ApprovalMatrix).
* Для прочих изменений состава (добавление/удаление) — пустой массив. * Для прочих изменений состава (добавление/удаление) — пустой массив.
* @param rowKeys ключи строк в ТЕКУЩЕМ порядке — единственный сигнал, по
* которому решаем, действительно ли что-то переставили (см. ниже, почему
* геометрии для этого недостаточно).
*/ */
// eslint-disable-next-line @rushstack/no-new-null -- null здесь диктует React: ref-колбэк получает именно null при отвязке узла export function useFlipRows(
export function useFlipRows(activeKeys: string[]): (key: string) => (el: HTMLTableRowElement | null) => void { activeKeys: string[],
rowKeys: string[]
// eslint-disable-next-line @rushstack/no-new-null -- null здесь диктует React: ref-колбэк получает именно null при отвязке узла
): (key: string) => (el: HTMLTableRowElement | null) => void {
const nodes = React.useRef(new Map<string, HTMLTableRowElement>()); const nodes = React.useRef(new Map<string, HTMLTableRowElement>());
// Колбэк на один и тот же ключ должен быть стабильным, иначе React дёргал бы // Колбэк на один и тот же ключ должен быть стабильным, иначе React дёргал бы
// ref(null)/ref(el) на каждый рендер и карта узлов пересобиралась бы впустую. // ref(null)/ref(el) на каждый рендер и карта узлов пересобиралась бы впустую.
const refCache = React.useRef(new Map<string, (el: HTMLTableRowElement | null) => void>()); const refCache = React.useRef(new Map<string, (el: HTMLTableRowElement | null) => void>());
const prevTops = React.useRef(new Map<string, number>()); const prevTops = React.useRef(new Map<string, number>());
// Порядок строк на прошлый коммит — см. проверку orderChanged ниже.
const prevRowKeys = React.useRef<string[]>([]);
// Свои анимации держим сами, чтобы отменять ИМЕННО их: el.getAnimations() // Свои анимации держим сами, чтобы отменять ИМЕННО их: el.getAnimations()
// вернул бы заодно чужие (CSS-переходы и т.п.). // вернул бы заодно чужие (CSS-переходы и т.п.).
const running = React.useRef<Animation[]>([]); const running = React.useRef<Animation[]>([]);
@@ -101,25 +142,45 @@ export function useFlipRows(activeKeys: string[]): (key: string) => (el: HTMLTab
// useEffect: инвертировать положение нужно до того, как браузер покажет кадр, // useEffect: инвертировать положение нужно до того, как браузер покажет кадр,
// иначе будет заметен скачок. // иначе будет заметен скачок.
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
// ВАЖНО: «строка сдвинулась по пикселям» — недостаточный повод анимировать.
// Пример живого бага: заявку отправили на согласование → таблица стала
// read-only → у строк пропали колонки со стрелками/кнопками → ширина
// текстовых колонок изменилась → где-то текст перенёсся по-другому →
// строки СМЕСТИЛИСЬ ПО ВЫСОТЕ, хотя их состав и порядок не менялись вовсе.
// Раньше это трактовалось как «переезд» и запускало translateY на
// случайную величину — на кадре это выглядело как строки, которые на
// мгновение пропадают (уезжают за пределы видимой области) и выскакивает
// скролл. Настоящая перестановка меняет НАБОР/ПОРЯДОК ключей — вот это и
// проверяем, а не сам факт смещения.
const orderChanged =
rowKeys.length !== prevRowKeys.current.length || rowKeys.some((k, i) => k !== prevRowKeys.current[i]);
prevRowKeys.current = rowKeys;
if (!orderChanged) {
// Порядок тот же — просто обновляем базовую геометрию для следующего
// ДЕЙСТВИТЕЛЬНОГО сравнения, без единой анимации на этом проходе.
prevTops.current = measureTops(nodes.current);
return;
}
// Сначала запоминаем, где строка ВИДНА сейчас: при быстрых повторных кликах // Сначала запоминаем, где строка ВИДНА сейчас: при быстрых повторных кликах
// она в этот момент ещё летит, и новая анимация обязана стартовать от её // она в этот момент ещё летит, и новая анимация обязана стартовать от её
// текущего положения на экране, иначе будет рывок. // текущего положения на экране, иначе будет рывок.
const visualTops = new Map<string, number>(); const visualTops = measureTops(nodes.current);
nodes.current.forEach((el, key) => visualTops.set(key, el.getBoundingClientRect().top));
running.current.forEach((a) => a.cancel()); running.current.forEach((a) => a.cancel());
running.current = []; running.current = [];
nodes.current.forEach(drop); nodes.current.forEach(drop);
const previous = prevTops.current; const previous = prevTops.current;
const current = new Map<string, number>(); // После отмены анимаций transform снят — это истинные новые положения.
const current = measureTops(nodes.current);
const moved: Array<{ el: HTMLTableRowElement; key: string; shift: number }> = []; const moved: Array<{ el: HTMLTableRowElement; key: string; shift: number }> = [];
const entered: HTMLTableRowElement[] = []; const entered: HTMLTableRowElement[] = [];
nodes.current.forEach((el, key) => { nodes.current.forEach((el, key) => {
// После отмены анимаций transform снят — это истинное новое положение. const layoutTop = current.get(key);
const layoutTop = el.getBoundingClientRect().top; if (layoutTop === undefined) return;
current.set(key, layoutTop);
const before = previous.get(key); const before = previous.get(key);
if (before === undefined) { if (before === undefined) {
@@ -1,14 +1,31 @@
import { IRoute, navigate, parseRoute, routeUrl } from './routing'; import {
IRoute,
navigate,
parseRoute,
persistOwnStatus,
persistOwnTab,
persistPage,
persistQuery,
routeUrl,
storedOwnStatus,
storedOwnTab,
storedPageFor,
storedQueryFor,
} from './routing';
// buildQuery/navigate читают и пишут window.location через history API — // buildQuery/navigate читают и пишут window.location через history API, а
// сбрасываем адрес перед каждым тестом, чтобы они не зависели от порядка запуска. // подвкладка/фильтр «Моих заявок» — ещё и localStorage (см. persistOwnTab/
// persistOwnStatus) — сбрасываем оба перед каждым тестом, чтобы они не
// зависели от порядка запуска.
beforeEach(() => { beforeEach(() => {
window.history.replaceState({}, '', '/'); window.history.replaceState({}, '', '/');
window.localStorage.clear();
}); });
describe('parseRoute', () => { describe('parseRoute', () => {
it('пустой адрес — подача заявки в разделе «Новая заявка»', () => { it('пустой адрес — подача заявки в разделе «Новая заявка»', () => {
expect(parseRoute('')).toEqual({ mode: 'submit', nav: 'new' }); // page всегда определён (1 — значение по умолчанию), в адрес не пишется.
expect(parseRoute('')).toEqual({ mode: 'submit', nav: 'new', page: 1, query: '' });
}); });
it('rpnav задаёт раздел бокового меню', () => { it('rpnav задаёт раздел бокового меню', () => {
@@ -29,6 +46,8 @@ describe('parseRoute', () => {
nav: 'new', nav: 'new',
formKey: 'hire', formKey: 'hire',
itemId: 42, itemId: 42,
page: 1,
query: '',
}); });
}); });
@@ -42,6 +61,8 @@ describe('parseRoute', () => {
nav: 'new', nav: 'new',
formKey: 'hire', formKey: 'hire',
itemId: 7, itemId: 7,
page: 1,
query: '',
}); });
}); });
@@ -124,6 +145,293 @@ describe('подвкладки и фильтр «Моих заявок»', () =>
expect(window.location.search).not.toContain('rptab'); expect(window.location.search).not.toContain('rptab');
expect(window.location.search).not.toContain('rpstatus'); expect(window.location.search).not.toContain('rpstatus');
}); });
});
describe('страница таблицы (rppage + persistPage)', () => {
it('без rppage — первая страница', () => {
expect(parseRoute('?rpnav=own').page).toBe(1);
});
it('rppage задаёт страницу', () => {
expect(parseRoute('?rpnav=own&rppage=3').page).toBe(3);
});
it('мусор в rppage трактуется как первая страница, а не роняет разбор', () => {
expect(parseRoute('?rpnav=own&rppage=0').page).toBe(1);
expect(parseRoute('?rpnav=own&rppage=-2').page).toBe(1);
expect(parseRoute('?rpnav=own&rppage=abc').page).toBe(1);
expect(parseRoute('?rpnav=own&rppage=1.5').page).toBe(1);
});
it('первая страница не засоряет адрес, непервая — попадает', () => {
expect(routeUrl({ mode: 'submit', nav: 'own', page: 1 })).not.toContain('rppage');
expect(routeUrl({ mode: 'submit', nav: 'own' })).not.toContain('rppage');
expect(routeUrl({ mode: 'submit', nav: 'own', page: 4 })).toContain('rppage=4');
});
it('round-trip страницы через navigate', () => {
navigate({ mode: 'submit', nav: 'approvals', page: 2 });
expect(parseRoute(window.location.search).page).toBe(2);
});
it('без rppage подставляется запомненная для ЭТОЙ таблицы страница', () => {
persistPage({ mode: 'submit', nav: 'approvals' }, 5);
expect(parseRoute('?rpnav=approvals').page).toBe(5);
});
it('страница запоминается отдельно по таблицам и не переносится между разделами', () => {
persistPage({ mode: 'submit', nav: 'approvals' }, 5);
// В другом разделе своя нумерация — чужая страница туда попасть не должна,
// иначе можно приземлиться на несуществующую (у него всего одна страница).
expect(parseRoute('?rpnav=execution').page).toBe(1);
});
it('подвкладки «Моих заявок» — тоже разные таблицы, у каждой своя страница', () => {
persistPage({ mode: 'submit', nav: 'own', ownTab: 'created' }, 3);
persistPage({ mode: 'submit', nav: 'own', ownTab: 'approved' }, 7);
expect(parseRoute('?rpnav=own&rptab=created').page).toBe(3);
expect(parseRoute('?rpnav=own&rptab=approved').page).toBe(7);
});
it('явная страница в адресе имеет приоритет над запомненной', () => {
persistPage({ mode: 'submit', nav: 'approvals' }, 5);
expect(parseRoute('?rpnav=approvals&rppage=2').page).toBe(2);
});
it('возврат на первую страницу стирает запомненную', () => {
persistPage({ mode: 'submit', nav: 'approvals' }, 5);
expect(parseRoute('?rpnav=approvals').page).toBe(5);
persistPage({ mode: 'submit', nav: 'approvals' }, 1);
expect(parseRoute('?rpnav=approvals').page).toBe(1);
});
it('страница переживает открытие заявки поверх списка', () => {
navigate({ mode: 'submit', nav: 'own', ownTab: 'created', page: 3 });
navigate({ mode: 'approve', nav: 'own', ownTab: 'created', formKey: 'hire', itemId: 42, page: 3 });
expect(parseRoute(window.location.search).page).toBe(3);
});
// Ради этого storedPageFor и заведён: при переходе по меню маршрут СТРОИТСЯ,
// и если не подставить запомненную страницу явно, она останется только в
// состоянии — в адресе будет первая, а на экране открытая (см. goto в
// RequestPortal.tsx).
it('storedPageFor отдаёт запомненную страницу нужной таблицы', () => {
persistPage({ mode: 'submit', nav: 'approvals' }, 4);
expect(storedPageFor('approvals')).toBe(4);
expect(storedPageFor('execution')).toBe(1);
});
it('storedPageFor различает подвкладки «Моих заявок»', () => {
persistPage({ mode: 'submit', nav: 'own', ownTab: 'rejected' }, 6);
expect(storedPageFor('own', 'rejected')).toBe(6);
expect(storedPageFor('own', 'created')).toBe(1);
});
it('storedPageFor без подвкладки берёт запомненную — ту, что и откроется', () => {
persistOwnTab('executed');
persistPage({ mode: 'submit', nav: 'own', ownTab: 'executed' }, 2);
expect(storedPageFor('own')).toBe(2);
});
it('переход по меню кладёт запомненную страницу в адрес', () => {
persistPage({ mode: 'submit', nav: 'approvals' }, 3);
navigate({ mode: 'submit', nav: 'approvals', page: storedPageFor('approvals') });
expect(window.location.search).toContain('rppage=3');
});
it('localStorage недоступен — страница просто первая, без падения', () => {
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').page).toBe(1);
} finally {
window.localStorage.getItem = original;
}
});
});
describe('поиск (rpq + persistQuery)', () => {
it('без rpq — пустой запрос', () => {
expect(parseRoute('?rpnav=own').query).toBe('');
});
it('rpq задаёт запрос, в т.ч. с пробелами и кириллицей', () => {
expect(parseRoute('?rpnav=own&rpq=%D0%B0%D0%B1%D0%B2%20%D0%B3').query).toBe('абв г');
});
it('пустой запрос не засоряет адрес, непустой — попадает', () => {
expect(routeUrl({ mode: 'submit', nav: 'own', query: '' })).not.toContain('rpq');
expect(routeUrl({ mode: 'submit', nav: 'own' })).not.toContain('rpq');
expect(routeUrl({ mode: 'submit', nav: 'own', query: 'бухгалтер' })).toContain('rpq=');
});
it('round-trip запроса через navigate', () => {
navigate({ mode: 'submit', nav: 'search', query: 'Горбенко' });
expect(parseRoute(window.location.search).query).toBe('Горбенко');
});
it('без rpq подставляется запомненный для ЭТОЙ таблицы запрос', () => {
persistQuery({ mode: 'submit', nav: 'search' }, 'бухгалтер');
expect(parseRoute('?rpnav=search').query).toBe('бухгалтер');
});
it('запрос запоминается отдельно по таблицам и не переносится между разделами', () => {
persistQuery({ mode: 'submit', nav: 'search' }, 'бухгалтер');
expect(parseRoute('?rpnav=approvals').query).toBe('');
});
it('подвкладки «Моих заявок» — тоже разные таблицы, у каждой свой запрос', () => {
persistQuery({ mode: 'submit', nav: 'own', ownTab: 'created' }, 'приём');
persistQuery({ mode: 'submit', nav: 'own', ownTab: 'approved' }, 'место');
expect(parseRoute('?rpnav=own&rptab=created').query).toBe('приём');
expect(parseRoute('?rpnav=own&rptab=approved').query).toBe('место');
});
it('очистка запроса стирает запомненный', () => {
persistQuery({ mode: 'submit', nav: 'search' }, 'бухгалтер');
expect(parseRoute('?rpnav=search').query).toBe('бухгалтер');
persistQuery({ mode: 'submit', nav: 'search' }, '');
expect(parseRoute('?rpnav=search').query).toBe('');
});
it('storedQueryFor отдаёт запомненный запрос нужной таблицы', () => {
persistQuery({ mode: 'submit', nav: 'approvals' }, 'заявка');
expect(storedQueryFor('approvals')).toBe('заявка');
expect(storedQueryFor('execution')).toBe('');
});
it('переход по меню кладёт восстановленный запрос в адрес', () => {
persistQuery({ mode: 'submit', nav: 'approvals' }, 'заявка');
navigate({ mode: 'submit', nav: 'approvals', query: storedQueryFor('approvals') || undefined });
expect(parseRoute(window.location.search).query).toBe('заявка');
});
it('replace не добавляет запись в историю — набор текста не засоряет «назад»', () => {
navigate({ mode: 'submit', nav: 'search' });
const before = window.history.length;
navigate({ mode: 'submit', nav: 'search', query: 'бух' }, { replace: true });
navigate({ mode: 'submit', nav: 'search', query: 'бухг' }, { replace: true });
expect(window.history.length).toBe(before);
expect(parseRoute(window.location.search).query).toBe('бухг');
});
it('localStorage недоступен — запрос просто пустой, без падения', () => {
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').query).toBe('');
} finally {
window.localStorage.getItem = original;
}
});
});
describe('persistOwnTab/persistOwnStatus — запоминание подвкладки/фильтра между визитами', () => {
it('без явного rptab в адресе подставляется запомненная подвкладка', () => {
persistOwnTab('rejected');
expect(parseRoute('?rpnav=own').ownTab).toBe('rejected');
});
it('без явного rpstatus подставляется запомненный фильтр (только на «Инициированных»)', () => {
persistOwnStatus('Черновик');
expect(parseRoute('?rpnav=own').ownStatus).toBe('Черновик');
// На вкладках с решениями запомненный фильтр ни при чём — там его нет по смыслу.
persistOwnTab('approved');
expect(parseRoute('?rpnav=own').ownStatus).toBeUndefined();
});
it('явный параметр в адресе имеет приоритет над запомненным значением', () => {
persistOwnTab('rejected');
expect(parseRoute('?rpnav=own&rptab=executed').ownTab).toBe('executed');
});
it('очистка фильтра persistOwnStatus убирает его и из последующих parseRoute', () => {
persistOwnStatus('Черновик');
expect(parseRoute('?rpnav=own').ownStatus).toBe('Черновик');
persistOwnStatus(undefined);
expect(parseRoute('?rpnav=own').ownStatus).toBeUndefined();
});
it('запомненное значение не протекает за пределы раздела «Мои заявки»', () => {
persistOwnTab('rejected');
expect(parseRoute('?rpnav=approvals').ownTab).toBeUndefined();
});
// Как и со страницей: при переходе маршрут СТРОИТСЯ, и без явной подстановки
// восстановленный фильтр остался бы только в состоянии — в адресе его бы не
// было (см. goto/gotoOwnTab в RequestPortal.tsx).
it('storedOwnStatus отдаёт фильтр только для вкладки «Инициированные»', () => {
persistOwnStatus('Отклонено');
expect(storedOwnStatus('created')).toBe('Отклонено');
expect(storedOwnStatus('approved')).toBeUndefined();
expect(storedOwnStatus('rejected')).toBeUndefined();
});
it('storedOwnTab отдаёт запомненную подвкладку', () => {
persistOwnTab('executed');
expect(storedOwnTab()).toBe('executed');
});
it('переход в «Мои заявки» кладёт восстановленный фильтр в адрес', () => {
persistOwnStatus('Согласовано');
const ownTab = storedOwnTab();
navigate({ mode: 'submit', nav: 'own', ownTab, ownStatus: storedOwnStatus(ownTab) });
expect(window.location.search).toContain('rpstatus=');
expect(parseRoute(window.location.search).ownStatus).toBe('Согласовано');
});
it('переход на подвкладку решений фильтр в адрес не тащит', () => {
persistOwnStatus('Согласовано');
navigate({ mode: 'submit', nav: 'own', ownTab: 'approved', ownStatus: storedOwnStatus('approved') });
expect(window.location.search).toContain('rptab=approved');
expect(window.location.search).not.toContain('rpstatus');
});
// Ровно тот баг, который это всё затевалось починить: подвкладка «Инициированные»
// с выбранным фильтром → уход на другую подвкладку → возврат — фильтр должен
// остаться прежним, а не сброситься на «Все статусы» (см. RequestPortal.tsx/
// gotoOwnTab — persistOwnTab НЕ трогает сохранённый статус).
it('переключение на другую подвкладку и обратно не стирает запомненный фильтр', () => {
persistOwnStatus('На согласовании');
expect(parseRoute('?rpnav=own').ownStatus).toBe('На согласовании');
persistOwnTab('approved');
persistOwnTab('created');
expect(parseRoute('?rpnav=own').ownStatus).toBe('На согласовании');
});
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 для подвкладки решений', () => { it('round-trip для подвкладки решений', () => {
navigate({ mode: 'submit', nav: 'own', ownTab: 'executed' }); navigate({ mode: 'submit', nav: 'own', ownTab: 'executed' });
@@ -167,6 +475,8 @@ describe('navigate', () => {
// нет только потому, что это значение по умолчанию. // нет только потому, что это значение по умолчанию.
ownTab: 'created', ownTab: 'created',
ownStatus: undefined, ownStatus: undefined,
page: 1,
query: '',
}); });
}); });
+211 -10
View File
@@ -58,10 +58,185 @@ export interface IRoute {
ownTab?: OwnTab; ownTab?: OwnTab;
/** Фильтр по статусу заявки; задан только при nav='own' и ownTab='created'. */ /** Фильтр по статусу заявки; задан только при nav='own' и ownTab='created'. */
ownStatus?: string; ownStatus?: string;
/** Номер страницы таблицы (1-based). 1 — значение по умолчанию, в адрес не пишется. */
page?: number;
/** Текст поиска по таблице. Пустой — значение по умолчанию, в адрес не пишется. */
query?: string;
} }
const ROUTE_CHANGE_EVENT = 'rp-route-change'; const ROUTE_CHANGE_EVENT = 'rp-route-change';
// Подвкладка и фильтр «Моих заявок» переживают перезагрузку страницы и новый
// визит (localStorage), но НЕ более того — как и текст поиска в
// usePersistedQuery.ts, это просто «продолжить с того же места», а не
// синхронизация между вкладками/устройствами.
//
// Читаются здесь же, в parseRoute, а не отдельным хуком в компоненте: URL уже
// является единственным источником правды для ownTab/ownStatus (см.
// buildQuery — он не пишет значения по умолчанию в адрес), поэтому именно тут,
// где URL молчит, естественно спросить «а что было в прошлый раз».
// Пишутся ТОЛЬКО через persistOwnTab/persistOwnStatus — то есть только там,
// где пользователь СОЗНАТЕЛЬНО меняет вкладку или фильтр (см.
// gotoOwnTab/setOwnStatus в RequestPortal.tsx), а не при каждой навигации:
// иначе, например, просто открыв и закрыв заявку, можно было бы затереть
// запомненное значение значением по умолчанию.
//
// НАМЕРЕННО два отдельных сеттера, а не один persistOwnSelection(tab, status):
// переключение НА подвкладку с решениями (approved/rejected/executed) должно
// запомнить только её саму, не трогая фильтр статуса — тот относится
// ИСКЛЮЧИТЕЛЬНО к «Инициированным» и при возврате туда должен найтись таким
// же, каким был оставлен. Один сеттер этого не даёт: при переключении на
// другую подвкладку он получал бы ownStatus=undefined (потому что на других
// подвкладках фильтра просто нет) и стирал бы запомненное значение — что и
// произошло на практике: уйдя с «Инициированных» на «Согласованные» и
// вернувшись обратно, фильтр каждый раз сбрасывался на «Все статусы».
const OWN_TAB_STORAGE_KEY = 'rp-own-tab';
const OWN_STATUS_STORAGE_KEY = 'rp-own-status';
// Страница запоминается ОТДЕЛЬНО ДЛЯ КАЖДОЙ таблицы (см. viewKeyOf): один общий
// номер переносился бы между разделами, где у каждого свой объём данных, —
// уйдя со страницы 3 «Моих заявок» в «На согласовании», где страница всего
// одна, пользователь попал бы в пустоту.
const PAGE_STORAGE_PREFIX = 'rp-page:';
// Текст поиска — тоже свой на каждую таблицу. Префикс НЕ меняем: под ним
// поисковые запросы уже лежат (раньше их хранил хук usePersistedQuery), и
// сохранённое у пользователей должно пережить переезд в маршрут.
const QUERY_STORAGE_PREFIX = 'rp-search-query:';
/**
* Ключ таблицы для запоминания страницы. Совпадает по смыслу с ListView в
* RequestsTableView (плюс 'search'): у каждой подвкладки «Моих заявок» свой
* список и, значит, своя страница.
*/
function viewKeyOf(nav: NavSection, ownTab: OwnTab | undefined): string {
return nav === 'own' ? `own:${ownTab ?? 'created'}` : nav;
}
function readStoredPage(viewKey: string): number | undefined {
try {
const raw = window.localStorage.getItem(PAGE_STORAGE_PREFIX + viewKey);
if (!raw) return undefined;
const page = Number(raw);
return Number.isInteger(page) && page > 1 ? page : undefined;
} catch {
return undefined;
}
}
/**
* Запомненная страница таблицы, на которую собираемся перейти. Нужна там, где
* маршрут СТРОИТСЯ (переход по меню/подвкладке): parseRoute подставил бы её и
* сам, но тогда номер остался бы только в состоянии и не попал бы в адрес —
* ссылка на список показывала бы не ту страницу, что открыта на экране.
*
* ownTab не задан (переход в «Мои заявки» из другого раздела) — берём
* запомненную подвкладку: именно она и откроется (см. parseOwnTab).
*/
export function storedPageFor(nav: NavSection, ownTab?: OwnTab): number {
const tab = nav === 'own' ? ownTab ?? readStoredOwnTab() : undefined;
return readStoredPage(viewKeyOf(nav, tab)) ?? 1;
}
function readStoredQuery(viewKey: string): string {
try {
return window.localStorage.getItem(QUERY_STORAGE_PREFIX + viewKey) || '';
} catch {
return '';
}
}
/** Запомненный текст поиска таблицы — по тем же причинам, что и storedPageFor. */
export function storedQueryFor(nav: NavSection, ownTab?: OwnTab): string {
const tab = nav === 'own' ? ownTab ?? readStoredOwnTab() : undefined;
return readStoredQuery(viewKeyOf(nav, tab));
}
/** Запомнить текст поиска текущей таблицы. */
export function persistQuery(route: IRoute, query: string): void {
const viewKey = viewKeyOf(route.nav, route.ownTab);
try {
if (query) {
window.localStorage.setItem(QUERY_STORAGE_PREFIX + viewKey, query);
} else {
window.localStorage.removeItem(QUERY_STORAGE_PREFIX + viewKey);
}
} catch {
// localStorage недоступен (приватный режим и т.п.) — просто не сохраняем.
}
}
/**
* Запомнить страницу текущей таблицы. Ключ выводится из самого маршрута —
* вызывающей стороне не нужно знать, как таблицы различаются между собой.
*/
export function persistPage(route: IRoute, page: number): void {
const viewKey = viewKeyOf(route.nav, route.ownTab);
try {
// Первая страница — значение по умолчанию: храним её ОТСУТСТВИЕМ ключа,
// иначе в localStorage копился бы мусор из единиц по каждой таблице.
if (page > 1) {
window.localStorage.setItem(PAGE_STORAGE_PREFIX + viewKey, String(page));
} else {
window.localStorage.removeItem(PAGE_STORAGE_PREFIX + viewKey);
}
} catch {
// localStorage недоступен (приватный режим и т.п.) — просто не сохраняем.
}
}
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;
}
}
/**
* Запомненные подвкладка и фильтр — как и storedPageFor, нужны там, где маршрут
* СТРОИТСЯ: parseRoute подставил бы их и сам, но тогда они остались бы только в
* состоянии и не попали бы в адрес (ссылка на раздел вела бы не туда, что
* открыто на экране).
*/
export function storedOwnTab(): OwnTab {
return readStoredOwnTab();
}
/** Вне вкладки «Инициированные» фильтра по статусу нет по смыслу — там отбор задаёт сама вкладка. */
export function storedOwnStatus(ownTab: OwnTab): string | undefined {
return ownTab === 'created' ? readStoredOwnStatus() : undefined;
}
/** Запомнить подвкладку «Моих заявок» — вызывать только при явном выборе пользователя. */
export function persistOwnTab(ownTab: OwnTab): void {
try {
window.localStorage.setItem(OWN_TAB_STORAGE_KEY, ownTab);
} catch {
// localStorage недоступен (приватный режим и т.п.) — просто не сохраняем.
}
}
/** Запомнить фильтр по статусу вкладки «Инициированные» — вызывать только при явном выборе пользователя. */
export function persistOwnStatus(ownStatus: string | undefined): void {
try {
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 { function parseNav(raw: string | undefined): NavSection {
if ( if (
raw === 'approvals' || raw === 'approvals' ||
@@ -76,8 +251,8 @@ function parseNav(raw: string | undefined): NavSection {
} }
function parseOwnTab(raw: string | undefined): OwnTab { function parseOwnTab(raw: string | undefined): OwnTab {
if (raw === 'approved' || raw === 'rejected' || raw === 'executed') return raw; if (raw === 'approved' || raw === 'rejected' || raw === 'executed' || raw === 'created') return raw;
return 'created'; return readStoredOwnTab();
} }
/** Разобрать строку запроса (по умолчанию — из адреса страницы) в маршрут. */ /** Разобрать строку запроса (по умолчанию — из адреса страницы) в маршрут. */
@@ -96,23 +271,34 @@ export function parseRoute(search: string = window.location.search): IRoute {
// Фильтр по статусу живёт только на вкладке «Инициированные»: у вкладок с // Фильтр по статусу живёт только на вкладке «Инициированные»: у вкладок с
// решениями отбор задаёт сама вкладка. // решениями отбор задаёт сама вкладка.
const ownTab = nav === 'own' ? parseOwnTab(params.get('rptab') ?? undefined) : undefined; 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;
// Страница: из адреса, иначе — запомненная для ЭТОЙ таблицы. Мусор в
// параметре (0, минус, буквы) трактуем как первую страницу, а не как ошибку:
// ссылка приходит извне, ронять из-за неё раздел незачем.
const pageRaw = Number(params.get('rppage'));
const page =
Number.isInteger(pageRaw) && pageRaw > 1 ? pageRaw : readStoredPage(viewKeyOf(nav, ownTab)) ?? 1;
// Текст поиска — из адреса, иначе запомненный для этой же таблицы.
const query = params.get('rpq') ?? readStoredQuery(viewKeyOf(nav, ownTab));
// Открытая заявка (панель поверх раздела) — при полном наборе параметров. // Открытая заявка (панель поверх раздела) — при полном наборе параметров.
// Раздел под панелью сохраняется целиком, вместе с подвкладкой и фильтром: // Раздел под панелью сохраняется целиком, вместе с подвкладкой, фильтром и
// закрыв панель, пользователь должен вернуться ровно в тот же список. // страницей: закрыв панель, пользователь должен вернуться ровно в тот же
// список и на то же место в нём.
if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) { if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) {
return { mode: 'approve', nav, formKey, itemId, ownTab, ownStatus }; return { mode: 'approve', nav, formKey, itemId, ownTab, ownStatus, page, query };
} }
// Редактирование собственной незавершённой заявки — та же панель, но с формой. // Редактирование собственной незавершённой заявки — та же панель, но с формой.
if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) { if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) {
return { mode: 'edit', nav, formKey, itemId, ownTab, ownStatus }; return { mode: 'edit', nav, formKey, itemId, ownTab, ownStatus, page, query };
} }
// Создание производной заявки с предзаполнением из исходной. // Создание производной заявки с предзаполнением из исходной.
if (formKey && sourceId && !isNaN(sourceId)) { if (formKey && sourceId && !isNaN(sourceId)) {
return { mode: 'create', nav: 'new', formKey, sourceId }; return { mode: 'create', nav: 'new', formKey, sourceId };
} }
return { mode: 'submit', nav, ownTab, ownStatus }; return { mode: 'submit', nav, ownTab, ownStatus, page, query };
} }
// Собрать query-строку маршрута, сохранив посторонние параметры страницы. // Собрать query-строку маршрута, сохранив посторонние параметры страницы.
@@ -125,6 +311,8 @@ function buildQuery(route: IRoute): string {
params.delete('rpsource'); params.delete('rpsource');
params.delete('rptab'); params.delete('rptab');
params.delete('rpstatus'); params.delete('rpstatus');
params.delete('rppage');
params.delete('rpq');
if (route.nav !== 'new') { if (route.nav !== 'new') {
params.set('rpnav', route.nav); params.set('rpnav', route.nav);
@@ -136,6 +324,9 @@ function buildQuery(route: IRoute): string {
if (ownTab !== 'created') params.set('rptab', ownTab); if (ownTab !== 'created') params.set('rptab', ownTab);
else if (route.ownStatus) params.set('rpstatus', route.ownStatus); else if (route.ownStatus) params.set('rpstatus', route.ownStatus);
} }
// Первая страница — тоже значение по умолчанию (см. выше про чистую ссылку).
if (route.page && route.page > 1) params.set('rppage', String(route.page));
if (route.query) params.set('rpq', route.query);
if ((route.mode === 'approve' || route.mode === 'edit') && route.formKey && route.itemId) { if ((route.mode === 'approve' || route.mode === 'edit') && route.formKey && route.itemId) {
params.set('rpmode', route.mode); params.set('rpmode', route.mode);
params.set('rptype', route.formKey); params.set('rptype', route.formKey);
@@ -153,9 +344,19 @@ function buildQuery(route: IRoute): string {
* Перейти на другой маршрут без перезагрузки страницы (SPA): обновляет * Перейти на другой маршрут без перезагрузки страницы (SPA): обновляет
* query-параметры адресной строки через history.pushState и оповещает * query-параметры адресной строки через history.pushState и оповещает
* useRoute() своим событием (popstate на pushState сам не срабатывает). * useRoute() своим событием (popstate на pushState сам не срабатывает).
*
* replace=true заменяет текущую запись истории вместо добавления новой —
* для набора текста в поиске: иначе каждая буква становилась бы отдельным
* шагом «назад», и чтобы уйти со страницы, пришлось бы стереть весь запрос
* по одному нажатию.
*/ */
export function navigate(route: IRoute): void { export function navigate(route: IRoute, options?: { replace?: boolean }): void {
window.history.pushState({}, '', buildQuery(route)); const url = buildQuery(route);
if (options?.replace) {
window.history.replaceState({}, '', url);
} else {
window.history.pushState({}, '', url);
}
window.dispatchEvent(new Event(ROUTE_CHANGE_EVENT)); window.dispatchEvent(new Event(ROUTE_CHANGE_EVENT));
} }
@@ -1,54 +1,5 @@
import * as React from 'react'; import * as React from 'react';
// Текст поиска переживает перезагрузку страницы (localStorage), но не более
// того — это просто «продолжить с того же места», а не синхронизация между
// вкладками/устройствами. key должен быть уникальным на раздел (см. вызовы в
// RequestsTableView/SearchView), иначе значения разных таблиц перепишут друг друга.
function readStored(storageKey: string): string {
try {
return window.localStorage.getItem(storageKey) || '';
} catch {
return '';
}
}
export function usePersistedQuery(key: string): [string, (value: string) => void] {
const storageKey = `rp-search-query:${key}`;
const [value, setValue] = React.useState<string>(() => readStored(storageKey));
// Компонент может продолжать жить, когда key поменялся: RequestsTableView —
// это один и тот же элемент для вкладок «На согласовании» и «Мои заявки»,
// меняется только проп kind. Инициализатор useState при этом повторно НЕ
// срабатывает, поэтому без перечитывания текст из одной вкладки оставался бы
// на экране во второй и записывался уже под её ключ — выглядело бы так, будто
// ключ у них общий.
const prevKey = React.useRef<string>(storageKey);
React.useEffect(() => {
if (prevKey.current === storageKey) return;
prevKey.current = storageKey;
setValue(readStored(storageKey));
}, [storageKey]);
const setPersisted = React.useCallback(
(next: string): void => {
setValue(next);
try {
if (next) {
window.localStorage.setItem(storageKey, next);
} else {
window.localStorage.removeItem(storageKey);
}
} catch {
// localStorage недоступен (приватный режим и т.п.) — просто не сохраняем.
}
},
[storageKey]
);
return [value, setPersisted];
}
// Задержка перед тем, как введённый текст уйдёт в запрос. Без неё поиск // Задержка перед тем, как введённый текст уйдёт в запрос. Без неё поиск
// срабатывает на КАЖДОЕ нажатие клавиши: «согласование» — это 12 полных циклов // срабатывает на КАЖДОЕ нажатие клавиши: «согласование» — это 12 полных циклов
// (Search + дочитывание через REST по каждому типу заявок), из которых нужен // (Search + дочитывание через REST по каждому типу заявок), из которых нужен
@@ -74,3 +25,56 @@ export function useDebouncedQuery(value: string, delayMs: number = SEARCH_DEBOUN
return debounced; return debounced;
} }
/**
* Связывает поле поиска с маршрутом: в поле текст меняется мгновенно, а в
* маршрут (и, значит, в адрес) уходит только после паузы в наборе.
*
* Раньше текст хранился прямо здесь, в localStorage (usePersistedQuery). Теперь
* источник правды — маршрут: так поиск попадает в ссылку и работает кнопка
* «назад», а запоминание между визитами делает persistQuery в shared/routing.ts.
*
* Неочевидное место — ref `pushed`: в нём лежит текст, на котором поле и
* маршрут в последний раз сошлись. Он нужен в ОБЕ стороны.
*
* Вниз (маршрут → поле): без него собственное же изменение маршрута
* возвращалось бы обратно в поле и затирало текст, набранный за время паузы:
* пользователь печатает «абвг», через 450 мс в маршрут уходит «абв», маршрут
* меняется, и обратная синхронизация вернула бы в поле «абв», съев «г».
* Поэтому поле перечитывается только от ВНЕШНИХ смен (другой раздел, «назад»).
*
* Вверх (поле → маршрут): сравнивать `debounced` нужно именно с `pushed`, а НЕ
* с текущим routeQuery. RequestsTableView — один экземпляр на все таблицы
* («На согласовании», «Мои заявки» и т.д.), и при переключении раздела меняется
* не поле, а routeQuery. Со сравнением по routeQuery это выглядело как «текст
* изменился» — и запрос ПРЕДЫДУЩЕЙ таблицы уезжал в новую (и там же
* сохранялся), из-за чего один и тот же поиск оказывался во всех разделах.
*/
export function useRoutedQuery(
routeQuery: string,
onQueryChange: (query: string) => void
): [string, (value: string) => void] {
const [input, setInput] = React.useState<string>(routeQuery);
const debounced = useDebouncedQuery(input);
const pushed = React.useRef<string>(routeQuery);
React.useEffect(() => {
if (debounced === pushed.current) return;
pushed.current = debounced;
onQueryChange(debounced);
// onQueryChange пересоздаётся на каждый рендер родителя — в зависимостях
// он гонял бы эффект вхолостую; важен только сам факт смены debounced.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debounced]);
React.useEffect(() => {
if (routeQuery === pushed.current) return;
// Пришло извне — принимаем как новую точку схождения. Иначе догоняющий
// debounced (в поле уже новый текст) тут же счёл бы себя правкой
// пользователя и отправил бы этот же текст обратно в маршрут.
pushed.current = routeQuery;
setInput(routeQuery);
}, [routeQuery]);
return [input, setInput];
}