Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/5
This commit was merged in pull request #5.
This commit is contained in:
@@ -0,0 +1,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"
|
||||||
@@ -12,6 +12,7 @@ import * as strings from 'RequestPortalWebPartStrings';
|
|||||||
import RequestPortal from './components/RequestPortal';
|
import RequestPortal from './components/RequestPortal';
|
||||||
import { IRequestPortalProps } from './components/IRequestPortalProps';
|
import { IRequestPortalProps } from './components/IRequestPortalProps';
|
||||||
import { initSP } from './shared/pnp/pnpjs';
|
import { initSP } from './shared/pnp/pnpjs';
|
||||||
|
import { installErrorLogging } from './shared/diagnostics';
|
||||||
import { ErrorOverlay } from './shared/ui';
|
import { ErrorOverlay } from './shared/ui';
|
||||||
|
|
||||||
export interface IRequestPortalWebPartProps {
|
export interface IRequestPortalWebPartProps {
|
||||||
@@ -41,6 +42,7 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected onInit(): Promise<void> {
|
protected onInit(): Promise<void> {
|
||||||
|
installErrorLogging(); // до всего остального — чтобы поймать и ранние отклонения
|
||||||
initSP(this.context); // единый клиент PnPjs для записи в списки
|
initSP(this.context); // единый клиент PnPjs для записи в списки
|
||||||
return this._getEnvironmentMessage().then(message => {
|
return this._getEnvironmentMessage().then(message => {
|
||||||
this._environmentMessage = message;
|
this._environmentMessage = message;
|
||||||
|
|||||||
@@ -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 } 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';
|
||||||
@@ -13,9 +26,10 @@ import { REQUEST_TYPES } from '../data/requests';
|
|||||||
import SubmitFeature from '../features/submit/SubmitFeature';
|
import SubmitFeature from '../features/submit/SubmitFeature';
|
||||||
import ApproveFeature from '../features/approve/ApproveFeature';
|
import ApproveFeature from '../features/approve/ApproveFeature';
|
||||||
import EditFeature from '../features/edit/EditFeature';
|
import EditFeature from '../features/edit/EditFeature';
|
||||||
import { RequestsTableView } from '../features/myRequests/RequestsTableView';
|
import { ListView, RequestsTableView } from '../features/myRequests/RequestsTableView';
|
||||||
|
import { OwnTabs } from '../features/myRequests/OwnTabs';
|
||||||
import { SearchView } from '../features/myRequests/SearchView';
|
import { SearchView } from '../features/myRequests/SearchView';
|
||||||
import { getPendingApproval } from '../features/myRequests/myRequests.service';
|
import { getNavCounts, INavCounts } from '../features/myRequests/myRequests.service';
|
||||||
import { IMyRequest } from '../features/myRequests/models/IMyRequest';
|
import { IMyRequest } from '../features/myRequests/models/IMyRequest';
|
||||||
import { isEditableStatus } from '../shared/approval';
|
import { isEditableStatus } from '../shared/approval';
|
||||||
|
|
||||||
@@ -26,6 +40,16 @@ import { isEditableStatus } from '../shared/approval';
|
|||||||
// 'approvals'/'own' — таблицы заявок. Клик по строке таблицы открывает заявку
|
// 'approvals'/'own' — таблицы заявок. Клик по строке таблицы открывает заявку
|
||||||
// в выезжающей панели ПОВЕРХ раздела (rpmode=approve&rptype=&rpid=), поэтому
|
// в выезжающей панели ПОВЕРХ раздела (rpmode=approve&rptype=&rpid=), поэтому
|
||||||
// раздел под ней сохраняется; та же ссылка приходит из потока Power Automate.
|
// раздел под ней сохраняется; та же ссылка приходит из потока Power Automate.
|
||||||
|
// Разделы, которые показывают таблицу заявок, и соответствующее ей
|
||||||
|
// представление. У 'own' оно зависит ещё и от подвкладки (см. ниже), поэтому
|
||||||
|
// здесь стоит заглушка — важен сам факт наличия ключа.
|
||||||
|
const LIST_VIEWS: Partial<Record<NavSection, ListView>> = {
|
||||||
|
approvals: 'approvals',
|
||||||
|
execution: 'execution',
|
||||||
|
rework: 'rework',
|
||||||
|
own: 'own:created',
|
||||||
|
};
|
||||||
|
|
||||||
const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context }) => {
|
const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context }) => {
|
||||||
const rootClasses = [styles.requestPortal, hasTeamsContext ? styles.teams : '']
|
const rootClasses = [styles.requestPortal, hasTeamsContext ? styles.teams : '']
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -33,7 +57,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const [navCollapsed, setNavCollapsed] = React.useState<boolean>(false);
|
const [navCollapsed, setNavCollapsed] = React.useState<boolean>(false);
|
||||||
const [approvalsCount, setApprovalsCount] = React.useState<number | undefined>(undefined);
|
const [navCounts, setNavCounts] = React.useState<INavCounts | undefined>(undefined);
|
||||||
|
|
||||||
const [activeCategoryId, setActiveCategoryId] = React.useState<string>(() => {
|
const [activeCategoryId, setActiveCategoryId] = React.useState<string>(() => {
|
||||||
if (route.mode === 'create' && route.formKey) {
|
if (route.mode === 'create' && route.formKey) {
|
||||||
@@ -43,22 +67,21 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
return DEFAULT_CATEGORY_ID;
|
return DEFAULT_CATEGORY_ID;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Счётчик на пункте «На согласовании». Обновляется по тому же событию, что и
|
// Счётчики на пунктах-очередях («На согласовании», «На исполнении»,
|
||||||
// сами таблицы (создание заявки, решение согласующего), и на монтировании
|
// «Доработка»). Обновляются по тому же событию, что и сами таблицы (создание
|
||||||
// (в т.ч. на обновление страницы). Когда сама вкладка «На согласовании»
|
// заявки, решение согласующего), и на монтировании (в т.ч. на обновление
|
||||||
// открыта, RequestsTableView сообщает точное число через onMyTurnCountChange
|
// страницы). Все три приходят из одного вызова getNavCounts — он же следит,
|
||||||
// (ниже) — это перезаписывает значение отсюда, чтобы не было гонки между
|
// чтобы цифры не разошлись с содержимым вкладок.
|
||||||
// двумя независимыми запросами одних и тех же данных.
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const load = (): void => {
|
const load = (): void => {
|
||||||
getPendingApproval(context)
|
getNavCounts(context)
|
||||||
.then((r) => {
|
.then((c) => {
|
||||||
if (!cancelled) setApprovalsCount(r.totalRows);
|
if (!cancelled) setNavCounts(c);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setApprovalsCount(undefined);
|
if (!cancelled) setNavCounts(undefined);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -100,7 +123,88 @@ 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();
|
||||||
navigate({ mode: 'submit', nav });
|
// Всё восстанавливаемое состояние раздела передаём ЯВНО, из запомненного.
|
||||||
|
// parseRoute подставил бы его и сам, но тогда оно осталось бы только в
|
||||||
|
// состоянии и не попало бы в адрес: ссылка на раздел вела бы на первую
|
||||||
|
// страницу без фильтра, а на экране была бы открыта другая.
|
||||||
|
//
|
||||||
|
// Брать route.ownTab/route.ownStatus здесь нельзя: при переходе ИЗ другого
|
||||||
|
// раздела они 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 => {
|
||||||
|
// Фильтр и страница — из запомненного для ЭТОЙ подвкладки (см. goto выше о
|
||||||
|
// том, почему не из route). На вкладках с решениями фильтра нет по смыслу —
|
||||||
|
// storedOwnStatus сам вернёт undefined.
|
||||||
|
persistOwnTab(ownTab);
|
||||||
|
navigate({
|
||||||
|
mode: 'submit',
|
||||||
|
nav: 'own',
|
||||||
|
ownTab,
|
||||||
|
ownStatus: storedOwnStatus(ownTab),
|
||||||
|
page: storedPageFor('own', ownTab),
|
||||||
|
query: storedQueryFor('own', ownTab) || undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setOwnStatus = (ownStatus: string): void => {
|
||||||
|
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 });
|
||||||
};
|
};
|
||||||
// Клик по строке таблицы. СВОЯ заявка в незавершённом статусе (черновик,
|
// Клик по строке таблицы. СВОЯ заявка в незавершённом статусе (черновик,
|
||||||
// возвращённая, отклонённая) открывается на редактирование, всё остальное —
|
// возвращённая, отклонённая) открывается на редактирование, всё остальное —
|
||||||
@@ -112,9 +216,25 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
nav: route.nav,
|
nav: route.nav,
|
||||||
formKey: request.formKey,
|
formKey: request.formKey,
|
||||||
itemId: request.id,
|
itemId: request.id,
|
||||||
|
ownTab: route.ownTab,
|
||||||
|
ownStatus: route.ownStatus,
|
||||||
|
// Страница и запрос — часть того же списка под панелью: без них ссылка на
|
||||||
|
// открытую заявку вела бы на первую страницу без поиска.
|
||||||
|
page: route.page,
|
||||||
|
query: route.query,
|
||||||
});
|
});
|
||||||
|
|
||||||
const closeDrawer = (): void => navigate({ mode: 'submit', nav: route.nav });
|
// Закрывая панель, возвращаемся ровно в тот список, из которого её открыли —
|
||||||
|
// включая подвкладку и фильтр «Моих заявок».
|
||||||
|
const closeDrawer = (): void =>
|
||||||
|
navigate({
|
||||||
|
mode: 'submit',
|
||||||
|
nav: route.nav,
|
||||||
|
ownTab: route.ownTab,
|
||||||
|
ownStatus: route.ownStatus,
|
||||||
|
page: route.page,
|
||||||
|
query: route.query,
|
||||||
|
});
|
||||||
|
|
||||||
// Закрытие панели РЕДАКТИРОВАНИЯ: там открыта форма, и её содержимое живёт
|
// Закрытие панели РЕДАКТИРОВАНИЯ: там открыта форма, и её содержимое живёт
|
||||||
// только в памяти — уходя без сохранения, пользователь теряет правки.
|
// только в памяти — уходя без сохранения, пользователь теряет правки.
|
||||||
@@ -132,7 +252,11 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
<SideNav
|
<SideNav
|
||||||
activeId={route.nav}
|
activeId={route.nav}
|
||||||
onChange={goto}
|
onChange={goto}
|
||||||
approvalsCount={approvalsCount}
|
counts={{
|
||||||
|
approvals: navCounts?.approvals,
|
||||||
|
execution: navCounts?.execution,
|
||||||
|
rework: navCounts?.rework,
|
||||||
|
}}
|
||||||
collapsed={navCollapsed}
|
collapsed={navCollapsed}
|
||||||
onToggleCollapsed={() => setNavCollapsed((v) => !v)}
|
onToggleCollapsed={() => setNavCollapsed((v) => !v)}
|
||||||
/>
|
/>
|
||||||
@@ -181,16 +305,33 @@ 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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(route.nav === 'approvals' || route.nav === 'own') && (
|
{route.nav === 'own' && (
|
||||||
|
<OwnTabs activeId={route.ownTab ?? 'created'} onChange={gotoOwnTab} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{LIST_VIEWS[route.nav] !== undefined && (
|
||||||
<RequestsTableView
|
<RequestsTableView
|
||||||
context={context}
|
context={context}
|
||||||
kind={route.nav === 'approvals' ? 'approvals' : 'own'}
|
view={
|
||||||
|
route.nav === 'own'
|
||||||
|
? (`own:${route.ownTab ?? 'created'}` as ListView)
|
||||||
|
: (LIST_VIEWS[route.nav] as ListView)
|
||||||
|
}
|
||||||
|
statusFilter={route.ownStatus ?? ''}
|
||||||
|
onStatusFilterChange={setOwnStatus}
|
||||||
|
page={route.page ?? 1}
|
||||||
|
onPageChange={setPage}
|
||||||
|
query={route.query ?? ''}
|
||||||
|
onQueryChange={setQuery}
|
||||||
onOpen={openRequest}
|
onOpen={openRequest}
|
||||||
onMyTurnCountChange={route.nav === 'approvals' ? setApprovalsCount : undefined}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -10,9 +10,13 @@ interface INavItem {
|
|||||||
icon: IconName;
|
icon: IconName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Порядок неслучаен: сверху действие (создать), затем три очереди — то, что
|
||||||
|
// ждёт пользователя прямо сейчас, — и только потом архив и поиск.
|
||||||
const ITEMS: INavItem[] = [
|
const ITEMS: INavItem[] = [
|
||||||
{ id: 'new', title: 'Новая заявка', icon: 'plus' },
|
{ id: 'new', title: 'Новая заявка', icon: 'plus' },
|
||||||
{ id: 'approvals', title: 'На согласовании', icon: 'user-check' },
|
{ id: 'approvals', title: 'На согласовании', icon: 'user-check' },
|
||||||
|
{ id: 'execution', title: 'На исполнении', icon: 'helmet' },
|
||||||
|
{ id: 'rework', title: 'Доработка', icon: 'edit' },
|
||||||
{ id: 'own', title: 'Мои заявки', icon: 'clipboard' },
|
{ id: 'own', title: 'Мои заявки', icon: 'clipboard' },
|
||||||
{ id: 'search', title: 'Поиск', icon: 'search' },
|
{ id: 'search', title: 'Поиск', icon: 'search' },
|
||||||
];
|
];
|
||||||
@@ -20,8 +24,8 @@ const ITEMS: INavItem[] = [
|
|||||||
interface ISideNavProps {
|
interface ISideNavProps {
|
||||||
activeId: NavSection;
|
activeId: NavSection;
|
||||||
onChange: (id: NavSection) => void;
|
onChange: (id: NavSection) => void;
|
||||||
/** Счётчик у пункта «На согласовании» (сколько ждут решения). */
|
/** Счётчики очередей: сколько заявок ждёт пользователя в каждом разделе. */
|
||||||
approvalsCount?: number;
|
counts?: Partial<Record<NavSection, number>>;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
onToggleCollapsed: () => void;
|
onToggleCollapsed: () => void;
|
||||||
}
|
}
|
||||||
@@ -31,7 +35,7 @@ interface ISideNavProps {
|
|||||||
export const SideNav: React.FC<ISideNavProps> = ({
|
export const SideNav: React.FC<ISideNavProps> = ({
|
||||||
activeId,
|
activeId,
|
||||||
onChange,
|
onChange,
|
||||||
approvalsCount,
|
counts,
|
||||||
collapsed,
|
collapsed,
|
||||||
onToggleCollapsed,
|
onToggleCollapsed,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -58,7 +62,8 @@ export const SideNav: React.FC<ISideNavProps> = ({
|
|||||||
{ITEMS.map((item) => {
|
{ITEMS.map((item) => {
|
||||||
const isActive = item.id === activeId;
|
const isActive = item.id === activeId;
|
||||||
const itemClasses = [styles.item, isActive ? styles.itemActive : ''].filter(Boolean).join(' ');
|
const itemClasses = [styles.item, isActive ? styles.itemActive : ''].filter(Boolean).join(' ');
|
||||||
const showCount = item.id === 'approvals' && !!approvalsCount;
|
const count = counts?.[item.id];
|
||||||
|
const showCount = !!count;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -73,7 +78,7 @@ export const SideNav: React.FC<ISideNavProps> = ({
|
|||||||
<Icon name={item.icon} size="sm" />
|
<Icon name={item.icon} size="sm" />
|
||||||
</span>
|
</span>
|
||||||
{!collapsed && <span className={styles.itemTitle}>{item.title}</span>}
|
{!collapsed && <span className={styles.itemTitle}>{item.title}</span>}
|
||||||
{showCount && <span className={styles.badge}>{approvalsCount}</span>}
|
{showCount && <span className={styles.badge}>{count}</span>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Подвкладки «Моих заявок». Оформление намеренно повторяет CategoryTabs
|
||||||
|
// (вкладки категорий на подаче заявки) — это тот же по смыслу элемент
|
||||||
|
// «переключатель раздела», и разнобой в двух местах портала выглядел бы ошибкой.
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: $sp-2;
|
||||||
|
border-bottom: 1px solid $gray-200;
|
||||||
|
margin-bottom: $sp-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: $gray-500;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: .2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color .18s, border-color .18s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $navy-800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
color: $navy-900;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom-color: $gold-500;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './OwnTabs.module.scss';
|
||||||
|
import { OwnTab } from '../../shared/routing';
|
||||||
|
|
||||||
|
interface IOwnTabItem {
|
||||||
|
id: OwnTab;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Суффикс «мной» намеренно опущен: раздел уже называется «Мои заявки», и внутри
|
||||||
|
// него он повторялся бы в каждой вкладке. Первая вкладка — про авторство,
|
||||||
|
// остальные три — про решения пользователя в ЧУЖИХ маршрутах.
|
||||||
|
const ITEMS: IOwnTabItem[] = [
|
||||||
|
{ id: 'created', title: 'Инициированные' },
|
||||||
|
{ id: 'approved', title: 'Согласованные' },
|
||||||
|
{ id: 'rejected', title: 'Отклонённые' },
|
||||||
|
{ id: 'executed', title: 'Выполненные' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface IOwnTabsProps {
|
||||||
|
activeId: OwnTab;
|
||||||
|
onChange: (id: OwnTab) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Горизонтальный переключатель подвкладок раздела «Мои заявки». */
|
||||||
|
export const OwnTabs: React.FC<IOwnTabsProps> = ({ activeId, onChange }) => (
|
||||||
|
<nav className={styles.tabs} aria-label="Разделы моих заявок">
|
||||||
|
{ITEMS.map((item) => {
|
||||||
|
const isActive = item.id === activeId;
|
||||||
|
const tabClasses = [styles.tab, isActive ? styles.active : ''].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
className={tabClasses}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
onClick={() => onChange(item.id)}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
@@ -34,6 +34,49 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Фильтр по статусу — только на вкладке «Инициированные». Стоит слева от поиска
|
||||||
|
// и прижат к нему: вместе это один блок «чем сузить список».
|
||||||
|
.statusFilter {
|
||||||
|
// appearance: none + своя SVG-галочка — как в shared/ui/Select. Нативная
|
||||||
|
// стрелка ОС рисуется своим шрифтом и рядом с остальными полями портала
|
||||||
|
// выглядит чужеродно (и по-разному в разных браузерах).
|
||||||
|
appearance: none;
|
||||||
|
margin-left: auto;
|
||||||
|
// Справа место под галочку; по вертикали — как у .search, чтобы оба контрола
|
||||||
|
// в шапке были одной высоты.
|
||||||
|
padding: 9px 34px 9px 13px;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background-color: $white;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%236B7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 12px center;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-900;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .18s;
|
||||||
|
|
||||||
|
@include input-focus-gold;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Когда фильтр есть, отступ до края уже задан им (margin-left: auto) — иначе
|
||||||
|
// поиск сам отжимается вправо.
|
||||||
|
.statusFilter + .search {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Цвет пунктов списка — те же цвета, что у StatusBadge (см. statusOptionClassName
|
||||||
|
// в RequestsTableView.tsx). Только color: background-color нативный <option>
|
||||||
|
// показывает не во всех браузерах одинаково, поэтому не используем.
|
||||||
|
.optionDraft { color: $gray-700; }
|
||||||
|
.optionOnApproval { color: $blue-600; }
|
||||||
|
.optionApproved { color: $green-600; }
|
||||||
|
.optionRejected { color: $red-500; }
|
||||||
|
.optionReturned { color: $amber-700; }
|
||||||
|
.optionExecution { color: $navy-800; }
|
||||||
|
.optionClosed { color: $gray-500; }
|
||||||
|
|
||||||
.search {
|
.search {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -155,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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
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 {
|
import {
|
||||||
|
ApprovalQueueKind,
|
||||||
filterPendingByQuery,
|
filterPendingByQuery,
|
||||||
|
getApprovalQueue,
|
||||||
|
getApprovalQueueIndex,
|
||||||
getLinkedTypes,
|
getLinkedTypes,
|
||||||
getOwnRequests,
|
getOwnRequests,
|
||||||
getPendingApproval,
|
getOwnRequestsByStatus,
|
||||||
getPendingApprovalIndex,
|
hydrateDecisionComments,
|
||||||
hydratePendingAuthors,
|
hydratePendingAuthors,
|
||||||
searchOwnRequests,
|
searchOwnRequests,
|
||||||
} from './myRequests.service';
|
} from './myRequests.service';
|
||||||
import { IMyRequest } from './models/IMyRequest';
|
import { IMyRequest } from './models/IMyRequest';
|
||||||
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
|
import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType, StatusBadge } from '../../shared/approval';
|
||||||
import { onRequestsChanged } from '../../shared/requestEvents';
|
import { 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';
|
||||||
@@ -26,16 +29,157 @@ const PAGE_SIZE = 20;
|
|||||||
const AUTO_REFRESH_MS = 10 * 60 * 1000;
|
const AUTO_REFRESH_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Что именно показывает таблица. Один компонент обслуживает все списки заявок
|
||||||
|
* портала, а различаются они только источником строк и набором колонок:
|
||||||
|
*
|
||||||
|
* 'approvals' | 'execution' — очереди действий (из строк маршрута);
|
||||||
|
* 'rework' — свои заявки, возвращённые на доработку;
|
||||||
|
* 'own:created' — свои заявки (+ фильтр по статусу);
|
||||||
|
* 'own:approved'|'own:rejected'|'own:executed' — история своих решений.
|
||||||
|
*/
|
||||||
|
export type ListView =
|
||||||
|
| 'approvals'
|
||||||
|
| 'execution'
|
||||||
|
| 'rework'
|
||||||
|
| 'own:created'
|
||||||
|
| 'own:approved'
|
||||||
|
| 'own:rejected'
|
||||||
|
| 'own:executed';
|
||||||
|
|
||||||
|
// Представления, которые строятся на строках маршрута пользователя, и их очередь
|
||||||
|
// в сервисе. Всё, чего здесь нет ('rework'/'own:created'), читается из списка
|
||||||
|
// данных по автору.
|
||||||
|
const QUEUE_BY_VIEW: { [key: string]: ApprovalQueueKind } = {
|
||||||
|
approvals: 'approvals',
|
||||||
|
execution: 'execution',
|
||||||
|
'own:approved': 'approved',
|
||||||
|
'own:rejected': 'rejected',
|
||||||
|
'own:executed': 'executed',
|
||||||
|
};
|
||||||
|
|
||||||
|
const HEADINGS: Record<ListView, string> = {
|
||||||
|
approvals: 'Ждут вашего согласования',
|
||||||
|
execution: 'Ждут вашего исполнения',
|
||||||
|
rework: 'Возвращены вам на доработку',
|
||||||
|
'own:created': 'Инициированные мной',
|
||||||
|
'own:approved': 'Согласованные мной',
|
||||||
|
'own:rejected': 'Отклонённые мной',
|
||||||
|
'own:executed': 'Выполненные мной',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_TEXTS: Record<ListView, string> = {
|
||||||
|
approvals: 'Нет заявок, ждущих вашего согласования.',
|
||||||
|
execution: 'Нет заявок, ждущих вашего исполнения.',
|
||||||
|
rework: 'Нет заявок, возвращённых вам на доработку.',
|
||||||
|
'own:created': 'Вы ещё не создавали заявок.',
|
||||||
|
'own:approved': 'Вы ещё ничего не согласовали.',
|
||||||
|
'own:rejected': 'Вы ещё ничего не отклоняли.',
|
||||||
|
'own:executed': 'Вы ещё ничего не выполнили.',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Цвет пункта в выпадающем списке фильтра — то же сопоставление статус→цвет,
|
||||||
|
// что в StatusBadge (VARIANT), только цвет текста, а не бейдж целиком: внутри
|
||||||
|
// нативного <option> нельзя отрендерить компонент, а background-color на нём
|
||||||
|
// браузеры показывают ненадёжно (в отличие от color). Если сопоставление там
|
||||||
|
// поменяется — поправить и здесь, второй раз найти дублирование негде.
|
||||||
|
function statusOptionClassName(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case REQUEST_STATUS.onApproval:
|
||||||
|
case REQUEST_STATUS.onExecution:
|
||||||
|
return styles.optionOnApproval;
|
||||||
|
case REQUEST_STATUS.approved:
|
||||||
|
case REQUEST_STATUS.executed:
|
||||||
|
return styles.optionApproved;
|
||||||
|
case REQUEST_STATUS.rejected:
|
||||||
|
return styles.optionRejected;
|
||||||
|
case REQUEST_STATUS.returned:
|
||||||
|
return styles.optionReturned;
|
||||||
|
case REQUEST_STATUS.execution:
|
||||||
|
return styles.optionExecution;
|
||||||
|
case REQUEST_STATUS.closed:
|
||||||
|
return styles.optionClosed;
|
||||||
|
default:
|
||||||
|
return styles.optionDraft;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Колонки: «Автор» нужен там, где заявка чужая; «Статус» — где он не следует из
|
||||||
|
// самого раздела. В истории решений важно и то, и другое: заявка чужая, а её
|
||||||
|
// текущий статус мог уйти дальше вашего решения.
|
||||||
|
//
|
||||||
|
// 'rework' — свои заявки (автор не нужен) И все с одним статусом «Возвращена на
|
||||||
|
// доработку», раз именно по нему раздел и отобран (см. getOwnRequestsByStatus):
|
||||||
|
// колонка из одинаковых плашек ничего не сообщает, только шумит. Та же логика,
|
||||||
|
// что у очередей согласования/исполнения.
|
||||||
|
//
|
||||||
|
// 'own:created' — статус нужен, только пока показаны ВСЕ заявки. Стоит выбрать
|
||||||
|
// его в фильтре, и колонка вырождается в тот же столбец одинаковых плашек,
|
||||||
|
// поэтому она зависит не от одного вида, а ещё и от фильтра.
|
||||||
|
//
|
||||||
|
// Статус решения, ОБЩИЙ для всех строк раздела, — если он есть, показывать его
|
||||||
|
// колонкой незачем (столбец одинаковых плашек), зато полезен комментарий
|
||||||
|
// согласующего: почему вернули или почему отклонили. Именно за этим в такие
|
||||||
|
// списки и заходят.
|
||||||
|
function decisionStatusFor(view: ListView, statusFilter: string): string | undefined {
|
||||||
|
if (view === 'rework') return REQUEST_STATUS.returned;
|
||||||
|
if (view === 'own:rejected') return REQUEST_STATUS.rejected;
|
||||||
|
if (
|
||||||
|
view === 'own:created' &&
|
||||||
|
(statusFilter === REQUEST_STATUS.returned || statusFilter === REQUEST_STATUS.rejected)
|
||||||
|
) {
|
||||||
|
return statusFilter;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ITableColumns {
|
||||||
|
author: boolean;
|
||||||
|
status: boolean;
|
||||||
|
/** Заголовок колонки с комментарием; undefined — колонки нет. */
|
||||||
|
commentLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnsFor(view: ListView, statusFilter: string): ITableColumns {
|
||||||
|
const decisionStatus = decisionStatusFor(view, statusFilter);
|
||||||
|
const commentLabel =
|
||||||
|
decisionStatus === REQUEST_STATUS.rejected
|
||||||
|
? 'Причина отклонения'
|
||||||
|
: decisionStatus === REQUEST_STATUS.returned
|
||||||
|
? 'Причина возврата'
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (view === 'approvals' || view === 'execution') return { author: true, status: false };
|
||||||
|
if (view === 'rework') return { author: false, status: false, commentLabel };
|
||||||
|
if (view === 'own:created') return { author: false, status: !statusFilter, commentLabel };
|
||||||
|
// История решений: заявка чужая, поэтому нужен автор. Статус — только если он
|
||||||
|
// не предопределён самой вкладкой («Отклонённые мной» — это всегда «Отклонено»,
|
||||||
|
// а вот у согласованных/выполненных заявка могла уйти дальше по маршруту).
|
||||||
|
return { author: true, status: !decisionStatus, commentLabel };
|
||||||
|
}
|
||||||
|
|
||||||
export interface IRequestsTableViewProps {
|
export interface IRequestsTableViewProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
/** 'approvals' — ждут решения текущего пользователя; 'own' — созданные им. */
|
view: ListView;
|
||||||
kind: 'approvals' | 'own';
|
/** Фильтр по статусу заявки; только для view='own:created'. '' — все статусы. */
|
||||||
|
statusFilter?: string;
|
||||||
|
onStatusFilterChange?: (status: string) => void;
|
||||||
|
/**
|
||||||
|
* Текущая страница (1-based) и её смена. Страница живёт в маршруте, а не
|
||||||
|
* внутри компонента: так она попадает в адрес (ссылку можно передать),
|
||||||
|
* переживает открытие/закрытие заявки и запоминается для этой таблицы
|
||||||
|
* (см. persistPage в shared/routing.ts).
|
||||||
|
*/
|
||||||
|
page: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
/**
|
||||||
|
* Текст поиска и его смена. Тоже живёт в маршруте (см. page выше): попадает
|
||||||
|
* в адрес, переживает открытие заявки и запоминается для этой таблицы.
|
||||||
|
*/
|
||||||
|
query: string;
|
||||||
|
onQueryChange: (query: string) => void;
|
||||||
/** Клик по строке — открыть заявку (в выезжающей панели). */
|
/** Клик по строке — открыть заявку (в выезжающей панели). */
|
||||||
onOpen: (request: IMyRequest) => void;
|
onOpen: (request: IMyRequest) => void;
|
||||||
/** Точное число «ждут решения СЕЙЧАС» (kind='approvals', без поиска) — для
|
|
||||||
* счётчика в SideNav, чтобы не гонять для него отдельный параллельный
|
|
||||||
* запрос, который мог бы разойтись по времени с тем, что видно в таблице. */
|
|
||||||
onMyTurnCountChange?: (count: number) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
@@ -46,35 +190,19 @@ 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[];
|
||||||
kind: 'approvals' | 'own';
|
view: ListView;
|
||||||
|
statusFilter: string;
|
||||||
onOpen: (request: IMyRequest) => void;
|
onOpen: (request: IMyRequest) => void;
|
||||||
}> = ({ rows, kind, onOpen }) => (
|
}> = ({ rows, view, statusFilter, onOpen }) => {
|
||||||
|
const columns = columnsFor(view, statusFilter);
|
||||||
|
return (
|
||||||
<div className={styles.tableWrap}>
|
<div className={styles.tableWrap}>
|
||||||
<table className={styles.table}>
|
<table className={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -83,8 +211,9 @@ const Table: React.FC<{
|
|||||||
<th className={styles.colType}>Тип</th>
|
<th className={styles.colType}>Тип</th>
|
||||||
<th className={styles.colTitle}>Название</th>
|
<th className={styles.colTitle}>Название</th>
|
||||||
<th className={styles.colDate}>Дата</th>
|
<th className={styles.colDate}>Дата</th>
|
||||||
{kind === 'approvals' && <th className={styles.colAuthor}>Автор</th>}
|
{columns.author && <th className={styles.colAuthor}>Автор</th>}
|
||||||
{kind === 'own' && <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>
|
||||||
@@ -107,14 +236,19 @@ const Table: React.FC<{
|
|||||||
<td className={styles.colType}>{r.requestTypeName}</td>
|
<td className={styles.colType}>{r.requestTypeName}</td>
|
||||||
<td className={styles.colTitle}>{r.title || `#${r.id}`}</td>
|
<td className={styles.colTitle}>{r.title || `#${r.id}`}</td>
|
||||||
<td className={styles.colDate}>{formatDate(r.created)}</td>
|
<td className={styles.colDate}>{formatDate(r.created)}</td>
|
||||||
{kind === 'approvals' && (
|
{columns.author && (
|
||||||
<td className={styles.colAuthor}>
|
<td className={styles.colAuthor}>
|
||||||
{[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'}
|
{[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
{kind === 'own' && (
|
{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>
|
||||||
@@ -122,7 +256,8 @@ const Table: React.FC<{
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Раздел с таблицей заявок: поиск по всем полям + вывод постранично.
|
// Раздел с таблицей заявок: поиск по всем полям + вывод постранично.
|
||||||
//
|
//
|
||||||
@@ -133,40 +268,62 @@ const Table: React.FC<{
|
|||||||
// результат мог быть неустойчивым (заявка то находилась, то нет, в зависимости
|
// результат мог быть неустойчивым (заявка то находилась, то нет, в зависимости
|
||||||
// от того, попала ли она в окно подстраховки); эту гибридную логику убрали.
|
// от того, попала ли она в окно подстраховки); эту гибридную логику убрали.
|
||||||
//
|
//
|
||||||
// 'own' без поиска — getOwnRequests (REST по AuthorId, страница режется в
|
// 'own:created' без фильтра и поиска — getOwnRequests (Search + REST-довесок на
|
||||||
// памяти). 'own' с поиском — searchOwnRequests (Search, RowLimit/StartRow).
|
// свежесть). С поиском — searchOwnRequests (Search, RowLimit/StartRow).
|
||||||
//
|
//
|
||||||
// 'approvals' без поиска — getPendingApproval, REST по ApproverId(+Status),
|
// Очереди и история решений ('approvals'/'execution'/'own:*' кроме created) без
|
||||||
// страница нарезается на СЕРВЕРЕ (сам берёт нужный срез индекса и дочитывает
|
// поиска — getApprovalQueue: REST по ApproverId(+Status), страница нарезается на
|
||||||
// автора только для него).
|
// СЕРВЕРЕ (сам берёт нужный срез индекса и дочитывает автора только для него).
|
||||||
// 'approvals' с поиском — область расширяется до «где пользователь вообще есть
|
// С поиском — getApprovalQueueIndex даёт ПОЛНЫЙ список этой же очереди без
|
||||||
// в маршруте» (getPendingApprovalIndex, ПОЛНЫЙ список без автора — постранично
|
// автора (постранично его тут не взять, т.к. фильтрация текстом идёт уже
|
||||||
// его тут не взять, т.к. фильтрация текстом идёт уже после), затем текстовый
|
// после), затем текстовый фильтр через Search (filterPendingByQuery), и только
|
||||||
// фильтр через Search (filterPendingByQuery), и только после фильтрации —
|
// после фильтрации — нарезка на страницы и дочитывание автора
|
||||||
// нарезка на страницы и дочитывание автора (hydratePendingAuthors) — тоже
|
// (hydratePendingAuthors) — тоже только для видимой страницы.
|
||||||
// только для видимой страницы, а не для всего отфильтрованного набора.
|
|
||||||
//
|
//
|
||||||
// Оба варианта перечитываются по событию rp-requests-changed
|
// Отбор по статусу ЗАЯВКИ ('rework' и фильтр на 'own:created') — НЕ через KQL:
|
||||||
|
// managed property для произвольной Choice-колонки ненадёжна (см.
|
||||||
|
// getOwnRequestsByStatus в myRequests.service.ts — там объяснение и ссылка на
|
||||||
|
// реальный инцидент). Вместо этого Search ищет только по автору (надёжная
|
||||||
|
// AuthorOWSUSER), а статус фильтруется в памяти по полям, дочитанным через
|
||||||
|
// REST; постраничность там же, в памяти.
|
||||||
|
//
|
||||||
|
// Все варианты перечитываются по событию rp-requests-changed
|
||||||
// (shared/requestEvents) — после создания заявки и после решения согласующего.
|
// (shared/requestEvents) — после создания заявки и после решения согласующего.
|
||||||
// Поиск идёт по индексу, поэтому свежая правка может проявиться не мгновенно.
|
// Поиск идёт по индексу, поэтому свежая правка может проявиться не мгновенно.
|
||||||
export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||||
context,
|
context,
|
||||||
kind,
|
view,
|
||||||
|
statusFilter = '',
|
||||||
|
onStatusFilterChange,
|
||||||
|
page,
|
||||||
|
onPageChange,
|
||||||
|
query,
|
||||||
|
onQueryChange,
|
||||||
onOpen,
|
onOpen,
|
||||||
onMyTurnCountChange,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
|
const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
|
||||||
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
|
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
|
||||||
const [totalRows, setTotalRows] = React.useState<number>(0);
|
const [totalRows, setTotalRows] = React.useState<number>(0);
|
||||||
const [page, setPage] = React.useState<number>(1);
|
|
||||||
const [query, setQuery] = usePersistedQuery(kind);
|
|
||||||
const [error, setError] = React.useState<string>('');
|
const [error, setError] = React.useState<string>('');
|
||||||
// query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что
|
|
||||||
// уходит в запрос (после паузы в наборе). Всё ниже завязано на debouncedQuery,
|
|
||||||
// иначе поиск бил бы запросом по каждой набранной букве.
|
|
||||||
const debouncedQuery = useDebouncedQuery(query);
|
|
||||||
|
|
||||||
React.useEffect(() => setPage(1), [debouncedQuery, kind]);
|
// input — то, что в поле ввода (меняется сразу); в маршрут значение уходит
|
||||||
|
// после паузы в наборе. Данные грузятся по МАРШРУТНОМУ query — он и так уже
|
||||||
|
// «отложенный», отдельный debounce здесь был бы вторым по счёту.
|
||||||
|
const [input, setInput] = useRoutedQuery(query, onQueryChange);
|
||||||
|
const debouncedQuery = query;
|
||||||
|
|
||||||
|
// Какой ИМЕННО таблице принадлежат лежащие в состоянии rows/totalRows.
|
||||||
|
// Компонент один на все разделы (см. LIST_VIEWS в RequestPortal), поэтому при
|
||||||
|
// переключении раздела его состояние ещё хранит данные ПРЕДЫДУЩЕГО, пока не
|
||||||
|
// отработает загрузка. Без этой отметки сброс страницы ниже принимал решение
|
||||||
|
// по чужому totalRows и сбивал страницу на первую — из-за чего запомненная
|
||||||
|
// страница затиралась при каждом переходе по левому меню.
|
||||||
|
// Страница в ключ НЕ входит: totalRows один на всю таблицу, а не на страницу.
|
||||||
|
const dataKey = `${view}|${statusFilter}|${debouncedQuery.trim()}`;
|
||||||
|
const [loadedKey, setLoadedKey] = React.useState<string>('');
|
||||||
|
|
||||||
|
// Страницу при смене запроса здесь НЕ сбрасываем: и запрос, и страница живут
|
||||||
|
// в маршруте, и меняются они одним переходом (см. setQuery в RequestPortal).
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -185,7 +342,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (types === undefined) return;
|
if (types === undefined) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const cacheKey = pageKey('table', [kind, debouncedQuery.trim(), page]);
|
const cacheKey = pageKey('table', [view, statusFilter, debouncedQuery.trim(), page]);
|
||||||
|
|
||||||
// Сразу показываем то, что уже загружали для ЭТОЙ вкладки (и этого запроса
|
// Сразу показываем то, что уже загружали для ЭТОЙ вкладки (и этого запроса
|
||||||
// и страницы), а обновление идёт фоном. Компонент один на обе вкладки —
|
// и страницы), а обновление идёт фоном. Компонент один на обе вкладки —
|
||||||
@@ -195,30 +352,56 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
const cached = readPage(cacheKey);
|
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 fetchPage = async (): Promise<IPage> => {
|
const q = debouncedQuery.trim();
|
||||||
if (kind === 'own') {
|
|
||||||
return debouncedQuery.trim().length > 0
|
const fetchRows = async (): Promise<IPage> => {
|
||||||
|
// «Доработка» — тот же список, что «Инициированные», просто со статусом,
|
||||||
|
// зашитым в раздел, а не выбранным в фильтре.
|
||||||
|
if (view === 'rework' || (view === 'own:created' && statusFilter)) {
|
||||||
|
const status = view === 'rework' ? REQUEST_STATUS.returned : statusFilter;
|
||||||
|
return getOwnRequestsByStatus(context, types, status, page, debouncedQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view === 'own:created') {
|
||||||
|
return q.length > 0
|
||||||
? searchOwnRequests(context, types, debouncedQuery, page)
|
? searchOwnRequests(context, types, debouncedQuery, page)
|
||||||
: getOwnRequests(context, types, page);
|
: getOwnRequests(context, types, page);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (debouncedQuery.trim().length === 0) {
|
const queue = QUEUE_BY_VIEW[view];
|
||||||
|
if (q.length === 0) {
|
||||||
// Без поиска — страница уже нарезана и дочитана сервером.
|
// Без поиска — страница уже нарезана и дочитана сервером.
|
||||||
const r = await getPendingApproval(context, 'myTurn', page);
|
const r = await getApprovalQueue(context, queue, page);
|
||||||
return { rows: r.rows, totalRows: r.totalRows };
|
return { rows: r.rows, totalRows: r.totalRows };
|
||||||
}
|
}
|
||||||
|
|
||||||
// С поиском — сначала полный список «где я есть в маршруте» (без
|
// С поиском — сначала полный список этой очереди (без автора), фильтруем
|
||||||
// автора), фильтруем текстом, и только потом режем на страницы и
|
// текстом, и только потом режем на страницы и дочитываем автора — только
|
||||||
// дочитываем автора — только для видимой страницы.
|
// для видимой страницы.
|
||||||
const idx = await getPendingApprovalIndex(context, 'involved');
|
const idx = await getApprovalQueueIndex(context, queue);
|
||||||
const filtered = await filterPendingByQuery(context, idx.types, idx.rows, debouncedQuery);
|
const filtered = await filterPendingByQuery(context, idx.types, idx.rows, debouncedQuery);
|
||||||
const pageRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
const pageRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||||
await hydratePendingAuthors(context, idx.types, pageRows);
|
await hydratePendingAuthors(context, idx.types, pageRows);
|
||||||
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();
|
||||||
@@ -233,10 +416,10 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
setRows(fresh.rows);
|
setRows(fresh.rows);
|
||||||
setTotalRows(fresh.totalRows);
|
setTotalRows(fresh.totalRows);
|
||||||
}
|
}
|
||||||
|
// Данные точно от текущей таблицы — теперь по ним можно судить о
|
||||||
|
// количестве страниц (см. сброс страницы ниже).
|
||||||
|
setLoadedKey(dataKey);
|
||||||
setError('');
|
setError('');
|
||||||
if (onMyTurnCountChange && kind === 'approvals' && debouncedQuery.trim().length === 0) {
|
|
||||||
onMyTurnCountChange(fresh.totalRows);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!cancelled) setError((e as Error).message);
|
if (!cancelled) setError((e as Error).message);
|
||||||
}
|
}
|
||||||
@@ -256,29 +439,58 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
unsubscribe();
|
unsubscribe();
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
};
|
};
|
||||||
}, [context, types, kind, debouncedQuery, page]);
|
}, [context, types, view, statusFilter, debouncedQuery, page]);
|
||||||
|
|
||||||
// Обе ветки теперь отдают уже нарезанную страницу — просто показываем как есть.
|
// Все ветки отдают уже нарезанную страницу — просто показываем как есть.
|
||||||
const q = debouncedQuery.trim();
|
const q = debouncedQuery.trim();
|
||||||
const total = totalRows;
|
const total = totalRows;
|
||||||
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));
|
||||||
|
|
||||||
const heading = kind === 'approvals' ? 'Ждут вашего решения' : 'Мои заявки';
|
// Страница может оказаться за пределами набора: пришла из старой ссылки или
|
||||||
|
// из запомненной, а заявок с тех пор стало меньше (часть согласовали, и они
|
||||||
|
// ушли из этой очереди). Показывать пустую таблицу с непустым счётчиком —
|
||||||
|
// выглядит как поломка, поэтому возвращаемся на последнюю существующую.
|
||||||
|
//
|
||||||
|
// Строго ПОСЛЕ проверки loadedKey: totalRows в состоянии может быть ещё от
|
||||||
|
// предыдущей таблицы (компонент общий), и решение по нему сбило бы страницу
|
||||||
|
// на первую при обычном переходе по левому меню.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (loadedKey !== dataKey) return;
|
||||||
|
if (rows && page > totalPages) onPageChange(totalPages);
|
||||||
|
}, [loadedKey, dataKey, rows, page, totalPages, onPageChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.view}>
|
<section className={styles.view}>
|
||||||
<div className={styles.head}>
|
<div className={styles.head}>
|
||||||
<h2 className={styles.heading}>
|
<h2 className={styles.heading}>
|
||||||
{heading}
|
{HEADINGS[view]}
|
||||||
{rows && <span className={styles.count}>{total}</span>}
|
{rows && <span className={styles.count}>{total}</span>}
|
||||||
</h2>
|
</h2>
|
||||||
|
{view === 'own:created' && onStatusFilterChange && (
|
||||||
|
<select
|
||||||
|
// Цвет самого <select> (закрытое состояние) — тем же классом, что и
|
||||||
|
// у выбранного <option>: statusFilter задаёт нейтральные border/фон,
|
||||||
|
// а конкретный statusOptionClassName(statusFilter) перекрывает color.
|
||||||
|
className={`${styles.statusFilter} ${statusFilter ? statusOptionClassName(statusFilter) : ''}`}
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => onStatusFilterChange(e.target.value)}
|
||||||
|
aria-label="Фильтр по статусу"
|
||||||
|
>
|
||||||
|
<option value="">Все статусы</option>
|
||||||
|
{REQUEST_STATUS_CHOICES.map((s) => (
|
||||||
|
<option key={s} value={s} className={statusOptionClassName(s)}>
|
||||||
|
{s}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
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>
|
||||||
|
|
||||||
@@ -286,17 +498,13 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
{!error && rows === undefined && <div className={styles.empty}>Загрузка…</div>}
|
{!error && rows === undefined && <div className={styles.empty}>Загрузка…</div>}
|
||||||
{!error && rows && total === 0 && (
|
{!error && rows && total === 0 && (
|
||||||
<div className={styles.empty}>
|
<div className={styles.empty}>
|
||||||
{q
|
{q ? `По запросу «${debouncedQuery}» ничего не найдено.` : EMPTY_TEXTS[view]}
|
||||||
? `По запросу «${debouncedQuery}» ничего не найдено.`
|
|
||||||
: kind === 'approvals'
|
|
||||||
? 'Нет заявок, ждущих вашего решения.'
|
|
||||||
: 'Вы ещё не создавали заявок.'}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!error && rows && total > 0 && (
|
{!error && rows && total > 0 && (
|
||||||
<>
|
<>
|
||||||
<Table rows={visible} kind={kind} 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>
|
||||||
|
|||||||
@@ -15,4 +15,18 @@ export interface IMyRequest {
|
|||||||
formKey: string;
|
formKey: string;
|
||||||
/** 'own' — заявка, созданная текущим пользователем; 'approval' — ждёт его решения. */
|
/** 'own' — заявка, созданная текущим пользователем; 'approval' — ждёт его решения. */
|
||||||
kind: 'own' | 'approval';
|
kind: 'own' | 'approval';
|
||||||
|
/**
|
||||||
|
* Статус СВОЕЙ строки маршрута (не заявки) — «На согласовании», «Согласовано»
|
||||||
|
* и т.п. Заполняется только у kind='approval'. Нужен, чтобы разнести заявки
|
||||||
|
* по очередям, не сканируя списки повторно (см. getMyTurnCounts). В таблицах
|
||||||
|
* не показывается — поэтому и не входит в pageSignature.
|
||||||
|
*/
|
||||||
|
myStepStatus?: string;
|
||||||
|
/**
|
||||||
|
* Комментарий согласующего к решению — причина возврата на доработку или
|
||||||
|
* отклонения (какое именно решение, задаёт вызывающая сторона). Живёт не в
|
||||||
|
* списке данных, а в строке МАРШРУТА, поэтому дочитывается отдельно и только
|
||||||
|
* там, где показывается (см. hydrateDecisionComments).
|
||||||
|
*/
|
||||||
|
decisionComment?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,9 +38,20 @@ const OWN_REST_TOP = 200;
|
|||||||
// ApproverOWSUSER в схеме поиска не находилось (разбирались 2026-08-06).
|
// ApproverOWSUSER в схеме поиска не находилось (разбирались 2026-08-06).
|
||||||
// Суффикс — тип исходного поля: OWSUSER для Person/Group.
|
// Суффикс — тип исходного поля: OWSUSER для Person/Group.
|
||||||
const MP_APPROVER = 'TansuSiteApproverOWSUSER';
|
const MP_APPROVER = 'TansuSiteApproverOWSUSER';
|
||||||
// Статус строки маршрута — Choice-поле, отсюда суффикс OWSCHCS (подтверждено
|
// Статус СТРОКИ МАРШРУТА (колонка ApprovalStatus списка «— согласование») —
|
||||||
// отладочным перебором 2026-08-06; у Person-полей он OWSUSER, см. MP_APPROVER).
|
// Choice-поле, отсюда суффикс OWSCHCS (подтверждено отладочным перебором
|
||||||
const MP_STATUS = 'RequestStatusOWSCHCS';
|
// 2026-08-06; у Person-полей он OWSUSER, см. MP_APPROVER). Колонку не путать с
|
||||||
|
// RequestStatus списка ДАННЫХ — это статус ЗАЯВКИ целиком.
|
||||||
|
//
|
||||||
|
// Для статуса ЗАЯВКИ (список ДАННЫХ) отдельной managed property здесь
|
||||||
|
// НАМЕРЕННО нет: имя для произвольной Choice-колонки в схеме поиска либо надо
|
||||||
|
// знать точно, либо угадывать по конвенции — угадали неверно (или колонка
|
||||||
|
// после переименования Status → RequestStatus ещё не переиндексирована), и
|
||||||
|
// фильтр по статусу молча возвращал 0 заявок. Поэтому статус заявки
|
||||||
|
// фильтруется НЕ в KQL, а в памяти после дочитывания реальных полей через REST
|
||||||
|
// (см. getOwnRequestsByStatus) — Search там ищет только по надёжному
|
||||||
|
// AuthorOWSUSER, статус ни при чём.
|
||||||
|
const MP_STATUS = 'ApprovalStatusOWSCHCS';
|
||||||
|
|
||||||
export function currentUserEmail(context: WebPartContext): string {
|
export function currentUserEmail(context: WebPartContext): string {
|
||||||
return (context.pageContext.user.email || '').toLowerCase();
|
return (context.pageContext.user.email || '').toLowerCase();
|
||||||
@@ -60,16 +71,41 @@ export async function getLinkedTypes(context: WebPartContext): Promise<IRequestT
|
|||||||
// пользователь участвует или будет участвовать.
|
// пользователь участвует или будет участвовать.
|
||||||
export type ApprovalScope = 'myTurn' | 'involved';
|
export type ApprovalScope = 'myTurn' | 'involved';
|
||||||
|
|
||||||
|
// Очереди и истории, построенные на СТРОКАХ МАРШРУТА текущего пользователя.
|
||||||
|
// Отличаются только набором статусов его строки — сам механизм (найти свои
|
||||||
|
// строки → взять их заявки) общий, поэтому статусы вынесены в параметр, а не
|
||||||
|
// зашиты в indexApprovalsForType.
|
||||||
|
//
|
||||||
|
// approvals/execution — очереди действий: ход за пользователем прямо сейчас.
|
||||||
|
// Раньше обе выдавались одним разделом «На согласовании» (фильтр включал
|
||||||
|
// оба статуса сразу); теперь это РАЗНЫЕ разделы и пересечения между ними
|
||||||
|
// нет — заявка ровно в одном из них.
|
||||||
|
// approved/rejected/executed — история уже принятых им решений
|
||||||
|
// (подвкладки «Моих заявок»).
|
||||||
|
export type ApprovalQueueKind = 'approvals' | 'execution' | 'approved' | 'rejected' | 'executed';
|
||||||
|
|
||||||
|
const QUEUE_STATUSES: Record<ApprovalQueueKind, string[]> = {
|
||||||
|
approvals: [REQUEST_STATUS.onApproval],
|
||||||
|
execution: [REQUEST_STATUS.onExecution],
|
||||||
|
approved: [REQUEST_STATUS.approved],
|
||||||
|
rejected: [REQUEST_STATUS.rejected],
|
||||||
|
executed: [REQUEST_STATUS.executed],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Статусы строки, означающие «ход за пользователем» — для счётчиков в меню
|
||||||
|
// (обе очереди считаются за один проход, см. getMyTurnCounts).
|
||||||
|
const MY_TURN_STATUSES: string[] = [REQUEST_STATUS.onApproval, REQUEST_STATUS.onExecution];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// «На согласовании» — раньше сюда приходилось читать ВЕСЬ список согласования
|
// «На согласовании» — раньше сюда приходилось читать ВЕСЬ список согласования
|
||||||
// (иначе на клиенте нечем было посчитать «чья очередь»). Теперь очередь
|
// (иначе на клиенте нечем было посчитать «чья очередь»). Теперь очередь
|
||||||
// проставляется при создании/решении (см. approvalList.service.ts/createSteps,
|
// проставляется при создании/решении (см. approvalList.service.ts/createSteps,
|
||||||
// approverDecision.service.ts/decide) — «чья сейчас очередь» это просто
|
// approverDecision.service.ts/decide) — «чья сейчас очередь» это просто
|
||||||
// значение колонки Status, поэтому здесь достаточно ОДНОГО отфильтрованного
|
// значение колонки ApprovalStatus, поэтому здесь достаточно ОДНОГО
|
||||||
// запроса: ApproverId (+ Status для 'myTurn'). Список согласования больше НЕ
|
// отфильтрованного запроса: ApproverId (+ ApprovalStatus для 'myTurn').
|
||||||
// сканируется целиком, независимо от его размера.
|
// Список согласования больше НЕ сканируется целиком, независимо от его размера.
|
||||||
//
|
//
|
||||||
// Заявку (Title/Status/Created) читаем сразу через expand по Lookup-полю
|
// Заявку (Title/RequestStatus/Created) читаем сразу через expand по Lookup-полю
|
||||||
// Ticket — отдельный запрос к списку данных нужен только за Author
|
// Ticket — отдельный запрос к списку данных нужен только за Author
|
||||||
// (двухуровневый expand Ticket/Author у SharePoint REST ненадёжен), и то —
|
// (двухуровневый expand Ticket/Author у SharePoint REST ненадёжен), и то —
|
||||||
// ТОЛЬКО для строк текущей страницы (см. hydrateAuthors ниже), а не для всего
|
// ТОЛЬКО для строк текущей страницы (см. hydrateAuthors ниже), а не для всего
|
||||||
@@ -93,13 +129,14 @@ const PENDING_PAGE_SIZE = 20;
|
|||||||
// см. filterPendingByQuery и вызывающую сторону в RequestsTableView).
|
// см. filterPendingByQuery и вызывающую сторону в RequestsTableView).
|
||||||
async function buildPendingIndex(
|
async function buildPendingIndex(
|
||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
scope: ApprovalScope
|
scope: ApprovalScope,
|
||||||
|
statuses: string[]
|
||||||
): Promise<{ types: IRequestType[]; index: IMyRequest[]; debug: IMyRequestsTypeDebug[] }> {
|
): Promise<{ types: IRequestType[]; index: IMyRequest[]; debug: IMyRequestsTypeDebug[] }> {
|
||||||
const types = await getLinkedTypes(context);
|
const types = await getLinkedTypes(context);
|
||||||
const tokenEmail = currentUserEmail(context);
|
const tokenEmail = currentUserEmail(context);
|
||||||
|
|
||||||
const perType = await Promise.all(
|
const perType = await Promise.all(
|
||||||
types.map((t) => indexApprovalsForType(context, t, tokenEmail, scope))
|
types.map((t) => indexApprovalsForType(context, t, tokenEmail, scope, statuses))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Свежие сверху — типы объединяются в один список, сортировать нужно после слияния.
|
// Свежие сверху — типы объединяются в один список, сортировать нужно после слияния.
|
||||||
@@ -112,12 +149,12 @@ async function buildPendingIndex(
|
|||||||
|
|
||||||
// Без текстового поиска: страница нарезается ЗДЕСЬ (сервер решает, какие 20
|
// Без текстового поиска: страница нарезается ЗДЕСЬ (сервер решает, какие 20
|
||||||
// заявок отдать), автор дочитывается только для них.
|
// заявок отдать), автор дочитывается только для них.
|
||||||
export async function getPendingApproval(
|
export async function getApprovalQueue(
|
||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
scope: ApprovalScope = 'myTurn',
|
kind: ApprovalQueueKind,
|
||||||
page: number = 1
|
page: number = 1
|
||||||
): Promise<IPendingApprovalPage> {
|
): Promise<IPendingApprovalPage> {
|
||||||
const { types, index, debug } = await buildPendingIndex(context, scope);
|
const { types, index, debug } = await buildPendingIndex(context, 'myTurn', QUEUE_STATUSES[kind]);
|
||||||
|
|
||||||
const pageRows = index.slice((page - 1) * PENDING_PAGE_SIZE, page * PENDING_PAGE_SIZE);
|
const pageRows = index.slice((page - 1) * PENDING_PAGE_SIZE, page * PENDING_PAGE_SIZE);
|
||||||
await hydrateAuthors(context, types, pageRows);
|
await hydrateAuthors(context, types, pageRows);
|
||||||
@@ -128,14 +165,59 @@ export async function getPendingApproval(
|
|||||||
// С текстовым поиском: полный индекс (без автора) — фильтрует его вызывающая
|
// С текстовым поиском: полный индекс (без автора) — фильтрует его вызывающая
|
||||||
// сторона через filterPendingByQuery, а страницу и автора берёт из уже
|
// сторона через filterPendingByQuery, а страницу и автора берёт из уже
|
||||||
// отфильтрованного результата (см. hydratePendingAuthors).
|
// отфильтрованного результата (см. hydratePendingAuthors).
|
||||||
|
//
|
||||||
|
// Область поиска — та же, что и у самой вкладки (статусы очереди уходят в KQL):
|
||||||
|
// раньше при вводе текста «На согласовании» расширялась до «где я вообще есть в
|
||||||
|
// маршруте», но теперь для сквозного поиска есть отдельный раздел «Поиск», а
|
||||||
|
// внутри вкладки предсказуемее искать по тому же набору, который в ней виден.
|
||||||
|
export async function getApprovalQueueIndex(
|
||||||
|
context: WebPartContext,
|
||||||
|
kind: ApprovalQueueKind
|
||||||
|
): Promise<{ rows: IMyRequest[]; types: IRequestType[]; debug: IMyRequestsTypeDebug[] }> {
|
||||||
|
const { types, index, debug } = await buildPendingIndex(context, 'involved', QUEUE_STATUSES[kind]);
|
||||||
|
return { rows: index, types, debug };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Полный индекс «где я вообще есть в маршруте», без отбора по статусу — нужен
|
||||||
|
// только разделу «Поиск» (см. searchMyRequests).
|
||||||
export async function getPendingApprovalIndex(
|
export async function getPendingApprovalIndex(
|
||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
scope: ApprovalScope
|
scope: ApprovalScope
|
||||||
): Promise<{ rows: IMyRequest[]; types: IRequestType[]; debug: IMyRequestsTypeDebug[] }> {
|
): Promise<{ rows: IMyRequest[]; types: IRequestType[]; debug: IMyRequestsTypeDebug[] }> {
|
||||||
const { types, index, debug } = await buildPendingIndex(context, scope);
|
const { types, index, debug } = await buildPendingIndex(context, scope, []);
|
||||||
return { rows: index, types, debug };
|
return { rows: index, types, debug };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Счётчики очередей для бокового меню. */
|
||||||
|
export interface INavCounts {
|
||||||
|
approvals: number;
|
||||||
|
execution: number;
|
||||||
|
rework: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// «На согласовании» и «На исполнении» считаются за ОДИН проход: разделять их по
|
||||||
|
// отдельным запросам значило бы дважды просканировать одни и те же списки
|
||||||
|
// согласования. Статус своей строки приходит в myStepStatus (см.
|
||||||
|
// indexApprovalsForType), по нему и разносим.
|
||||||
|
//
|
||||||
|
// «Доработка» живёт в других списках (данные, по автору), поэтому считается
|
||||||
|
// своим запросом — но параллельно, и ровно тем же вызовом, что наполняет саму
|
||||||
|
// вкладку: иначе счётчик и список могли бы разойтись.
|
||||||
|
export async function getNavCounts(context: WebPartContext): Promise<INavCounts> {
|
||||||
|
const types = await getLinkedTypes(context);
|
||||||
|
|
||||||
|
const [turn, rework] = await Promise.all([
|
||||||
|
buildPendingIndex(context, 'myTurn', MY_TURN_STATUSES),
|
||||||
|
getOwnRequestsByStatus(context, types, REQUEST_STATUS.returned, 1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
approvals: turn.index.filter((r) => r.myStepStatus === REQUEST_STATUS.onApproval).length,
|
||||||
|
execution: turn.index.filter((r) => r.myStepStatus === REQUEST_STATUS.onExecution).length,
|
||||||
|
rework: rework.totalRows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Публичная обёртка над hydrateAuthors — для дочитывания автора по уже
|
// Публичная обёртка над hydrateAuthors — для дочитывания автора по уже
|
||||||
// отфильтрованной и нарезанной на страницу выборке (см. getPendingApprovalIndex).
|
// отфильтрованной и нарезанной на страницу выборке (см. getPendingApprovalIndex).
|
||||||
export async function hydratePendingAuthors(
|
export async function hydratePendingAuthors(
|
||||||
@@ -147,7 +229,7 @@ export async function hydratePendingAuthors(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Лёгкий индекс совпадающих заявок для одного типа: строки согласования по
|
// Лёгкий индекс совпадающих заявок для одного типа: строки согласования по
|
||||||
// ApproverId → Title/Status/Created заявки отдельным запросом к списку ДАННЫХ
|
// ApproverId → Title/RequestStatus/Created заявки отдельным запросом к списку ДАННЫХ
|
||||||
// (НЕ через expand по Lookup-полю Ticket — SharePoint REST не отдаёт так
|
// (НЕ через expand по Lookup-полю Ticket — SharePoint REST не отдаёт так
|
||||||
// произвольные поля связанного элемента, только через Person-поля вроде
|
// произвольные поля связанного элемента, только через Person-поля вроде
|
||||||
// Author; попытка `expand('Ticket').select('Ticket/Status', ...)` падает с 400).
|
// Author; попытка `expand('Ticket').select('Ticket/Status', ...)` падает с 400).
|
||||||
@@ -156,7 +238,8 @@ async function indexApprovalsForType(
|
|||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
type: IRequestType,
|
type: IRequestType,
|
||||||
tokenEmail: string,
|
tokenEmail: string,
|
||||||
scope: ApprovalScope
|
scope: ApprovalScope,
|
||||||
|
statuses: string[]
|
||||||
): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug }> {
|
): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug }> {
|
||||||
const debug: IMyRequestsTypeDebug = {
|
const debug: IMyRequestsTypeDebug = {
|
||||||
requestTypeId: type.id,
|
requestTypeId: type.id,
|
||||||
@@ -186,8 +269,8 @@ async function indexApprovalsForType(
|
|||||||
// SharePoint REST — резолвим email в числовой Id пользователя (как и везде в
|
// SharePoint REST — резолвим email в числовой Id пользователя (как и везде в
|
||||||
// проекте, см. ensureUser) и фильтруем по TansuSiteApproverId.
|
// проекте, см. ensureUser) и фильтруем по TansuSiteApproverId.
|
||||||
//
|
//
|
||||||
// RequestStatus в $filter НЕ используем: подтверждено на практике — фильтр
|
// ApprovalStatus в $filter НЕ используем: подтверждено на практике — фильтр
|
||||||
// вида `RequestStatus eq 'На согласовании'` (кириллица в $filter) молча
|
// вида `ApprovalStatus eq 'На согласовании'` (кириллица в $filter) молча
|
||||||
// возвращает 0 строк, даже когда без него те же строки находятся с этим же
|
// возвращает 0 строк, даже когда без него те же строки находятся с этим же
|
||||||
// значением статуса. Поэтому фильтруем только по TansuSiteApproverId
|
// значением статуса. Поэтому фильтруем только по TansuSiteApproverId
|
||||||
// (числовое значение, проблем нет), а статус сравниваем в JS после
|
// (числовое значение, проблем нет), а статус сравниваем в JS после
|
||||||
@@ -208,23 +291,18 @@ async function indexApprovalsForType(
|
|||||||
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
|
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
|
||||||
const recentRows = (await approvalList.items
|
const recentRows = (await approvalList.items
|
||||||
.filter(`TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'`)
|
.filter(`TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'`)
|
||||||
.select('Id', 'TicketId', 'RequestStatus')
|
.select('Id', 'TicketId', 'ApprovalStatus')
|
||||||
.top(2000)()) as Array<Record<string, unknown>>;
|
.top(2000)()) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
// Статусы уходят в KQL: сервер сразу отдаёт только активные строки,
|
// Статусы уходят в KQL: сервер сразу отдаёт только нужные строки,
|
||||||
// а не всё, где пользователь есть в маршруте.
|
// а не всё, где пользователь есть в маршруте.
|
||||||
const searchRows = await getInvolvedRowsViaSearch(context, type, approvalList, [
|
const searchRows = await getInvolvedRowsViaSearch(context, type, approvalList, statuses);
|
||||||
REQUEST_STATUS.onApproval,
|
|
||||||
REQUEST_STATUS.onExecution,
|
|
||||||
]);
|
|
||||||
const seenIds = new Set(recentRows.map((r) => r.Id));
|
const seenIds = new Set(recentRows.map((r) => r.Id));
|
||||||
const allRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id)));
|
const allRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id)));
|
||||||
|
|
||||||
stepRows = allRows.filter(
|
// REST-довесок берётся по Modified без учёта статуса (кириллица в $filter
|
||||||
(r) =>
|
// ненадёжна, см. выше) — отбор по статусу делаем здесь, на клиенте.
|
||||||
r.RequestStatus === REQUEST_STATUS.onApproval ||
|
stepRows = allRows.filter((r) => statuses.indexOf(r.ApprovalStatus as string) !== -1);
|
||||||
r.RequestStatus === REQUEST_STATUS.onExecution
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// 'involved' — используется ТОЛЬКО для текстового поиска (см.
|
// 'involved' — используется ТОЛЬКО для текстового поиска (см.
|
||||||
// getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная
|
// getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная
|
||||||
@@ -233,7 +311,7 @@ async function indexApprovalsForType(
|
|||||||
// давал неустойчивый результат (заявка то находилась, то нет, в
|
// давал неустойчивый результат (заявка то находилась, то нет, в
|
||||||
// зависимости от того, успел ли Search её проиндексировать к моменту
|
// зависимости от того, успел ли Search её проиндексировать к моменту
|
||||||
// REST-окна).
|
// REST-окна).
|
||||||
stepRows = await getInvolvedRowsViaSearch(context, type, approvalList);
|
stepRows = await getInvolvedRowsViaSearch(context, type, approvalList, statuses);
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.approvalRowCount = stepRows.length;
|
debug.approvalRowCount = stepRows.length;
|
||||||
@@ -243,6 +321,18 @@ async function indexApprovalsForType(
|
|||||||
const ticketIds = Array.from(
|
const ticketIds = Array.from(
|
||||||
new Set(stepRows.map((r) => r.TicketId as number | undefined).filter((id): id is number => !!id))
|
new Set(stepRows.map((r) => r.TicketId as number | undefined).filter((id): id is number => !!id))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Статус СВОЕЙ строки по заявке — нужен, чтобы разнести заявки по очередям
|
||||||
|
// одним проходом (см. getMyTurnCounts). Если строк по заявке несколько,
|
||||||
|
// берём первую подошедшую: все они уже отобраны по statuses, а очередь у
|
||||||
|
// заявки на практике одна.
|
||||||
|
const stepStatusByTicket = new Map<number, string>();
|
||||||
|
stepRows.forEach((r) => {
|
||||||
|
const ticketId = r.TicketId as number | undefined;
|
||||||
|
if (ticketId && !stepStatusByTicket.has(ticketId)) {
|
||||||
|
stepStatusByTicket.set(ticketId, (r.ApprovalStatus as string) || '');
|
||||||
|
}
|
||||||
|
});
|
||||||
if (ticketIds.length === 0) {
|
if (ticketIds.length === 0) {
|
||||||
debug.approvalPendingTickets = 0;
|
debug.approvalPendingTickets = 0;
|
||||||
return { rows: [], debug };
|
return { rows: [], debug };
|
||||||
@@ -253,10 +343,13 @@ async function indexApprovalsForType(
|
|||||||
const ticketFilter = ticketIds.map((id) => `Id eq ${id}`).join(' or ');
|
const ticketFilter = ticketIds.map((id) => `Id eq ${id}`).join(' or ');
|
||||||
const ticketRows = (await list.items
|
const ticketRows = (await list.items
|
||||||
.filter(ticketFilter)
|
.filter(ticketFilter)
|
||||||
.select('Id', 'Title', 'Status', 'Created')
|
.select('Id', 'Title', 'RequestStatus', 'Created')
|
||||||
.top(ticketIds.length)()) as Array<Record<string, unknown>>;
|
.top(ticketIds.length)()) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
const rows = ticketRows.map((r) => toMyRequest(r, undefined, type, 'approval'));
|
const rows = ticketRows.map((r) => ({
|
||||||
|
...toMyRequest(r, undefined, type, 'approval'),
|
||||||
|
myStepStatus: stepStatusByTicket.get(r.Id as number),
|
||||||
|
}));
|
||||||
|
|
||||||
debug.approvalPendingTickets = rows.length;
|
debug.approvalPendingTickets = rows.length;
|
||||||
return { rows, debug };
|
return { rows, debug };
|
||||||
@@ -339,7 +432,7 @@ async function getInvolvedRowsViaSearch(
|
|||||||
|
|
||||||
return (await approvalList.items
|
return (await approvalList.items
|
||||||
.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
|
.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
|
||||||
.select('Id', 'TicketId', 'RequestStatus')
|
.select('Id', 'TicketId', 'ApprovalStatus')
|
||||||
.top(ids.length)()) as Array<Record<string, unknown>>;
|
.top(ids.length)()) as Array<Record<string, unknown>>;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -482,7 +575,7 @@ async function loadByIds(
|
|||||||
const rows = (await sp.web
|
const rows = (await sp.web
|
||||||
.getList(type.listId as string)
|
.getList(type.listId as string)
|
||||||
.items.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
|
.items.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
|
||||||
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
|
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
||||||
.expand('Author')
|
.expand('Author')
|
||||||
.top(ids.length)()) as Array<Record<string, unknown>>;
|
.top(ids.length)()) as Array<Record<string, unknown>>;
|
||||||
rows.forEach((r) => {
|
rows.forEach((r) => {
|
||||||
@@ -494,23 +587,20 @@ async function loadByIds(
|
|||||||
return byKey;
|
return byKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Свежие (созданные за последние RECENT_MS) заявки автора — обычным REST-ом,
|
// Последние OWN_REST_TOP заявок автора одного типа обычным REST-ом, в обход
|
||||||
// в обход Search (индекс мог их ещё не подхватить). Без текстового запроса —
|
// Search — сырьё для довеска по свежести (см. getFreshOwnRowsForType).
|
||||||
// матчить название не нужно, отдаём как есть.
|
// Фильтруем только по AuthorId: статус в $filter не используем, потому что
|
||||||
async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
|
// кириллица в сравнении там уже подводила (см. indexApprovalsForType) — отбор
|
||||||
|
// по статусу делает вызывающая сторона.
|
||||||
|
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);
|
||||||
const me = await sp.web.currentUser.select('Id')();
|
const me = await sp.web.currentUser.select('Id')();
|
||||||
const cutoff = new Date(Date.now() - RECENT_MS).toISOString();
|
|
||||||
|
|
||||||
// Фильтруем только по AuthorId, а «свежая или черновик» решаем в JS: статус
|
|
||||||
// в $filter не используем, потому что кириллица в сравнении там уже подводила
|
|
||||||
// (см. indexApprovalsForType). Берём последние OWN_REST_TOP заявок автора —
|
|
||||||
// это его собственные записи, их немного, и запрос ограничен по объёму.
|
|
||||||
const rows = (await sp.web
|
const rows = (await sp.web
|
||||||
.getList(type.listId as string)
|
.getList(type.listId as string)
|
||||||
.items.filter(`AuthorId eq ${me.Id}`)
|
.items.filter(`AuthorId eq ${me.Id}`)
|
||||||
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
|
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
||||||
.expand('Author')
|
.expand('Author')
|
||||||
.orderBy('Created', false)
|
.orderBy('Created', false)
|
||||||
.top(OWN_REST_TOP)()) as Array<Record<string, unknown>>;
|
.top(OWN_REST_TOP)()) as Array<Record<string, unknown>>;
|
||||||
@@ -521,17 +611,57 @@ async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestTyp
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Черновик берём ЛЮБОГО возраста: он живёт только у автора, правится часто и
|
return rows.map((r) =>
|
||||||
// именно по нему индекс Search отстаёт заметнее всего, а увидеть свой
|
toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own')
|
||||||
// черновик в «Моих заявках» пользователь ожидает сразу.
|
);
|
||||||
return rows
|
|
||||||
.filter((r) => {
|
|
||||||
const created = (r.Created as string) || '';
|
|
||||||
return r.Status === REQUEST_STATUS.draft || created >= cutoff;
|
|
||||||
})
|
|
||||||
.map((r) => toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own'));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[Текущие заявки] «${type.name}»: не удалось дочитать свежие заявки:`, (e as Error).message);
|
console.error(`[Текущие заявки] «${type.name}»: не удалось прочитать свои заявки:`, (e as Error).message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Свежие (созданные за последние RECENT_MS) заявки автора — довесок к Search,
|
||||||
|
// индекс мог их ещё не подхватить.
|
||||||
|
async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
|
||||||
|
const cutoff = new Date(Date.now() - RECENT_MS).toISOString();
|
||||||
|
|
||||||
|
// Черновик берём ЛЮБОГО возраста: он живёт только у автора, правится часто и
|
||||||
|
// именно по нему индекс Search отстаёт заметнее всего, а увидеть свой
|
||||||
|
// черновик в «Моих заявках» пользователь ожидает сразу.
|
||||||
|
return (await getOwnRowsForType(context, type)).filter(
|
||||||
|
(r) => r.status === REQUEST_STATUS.draft || r.created >= cutoff
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заявки автора, изменённые за последние RECENT_MS, — довесок к Search для
|
||||||
|
// getOwnRequestsByStatus. В отличие от getFreshOwnRowsForType (которая ловит
|
||||||
|
// НОВЫЕ заявки по дате СОЗДАНИЯ), здесь важна дата ИЗМЕНЕНИЯ: заявку могли
|
||||||
|
// вернуть на доработку неделю спустя после создания — по Created такую не
|
||||||
|
// найти, а Modified у неё обновится ровно в момент решения согласующего
|
||||||
|
// (см. approverDecision.service.ts/decide). Filter по Modified — обычная дата,
|
||||||
|
// не Choice-колонка, кириллицы в значении нет, поэтому $filter здесь надёжен
|
||||||
|
// (в отличие от статуса, см. getOwnRequestsByStatus).
|
||||||
|
async function getRecentlyChangedOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
|
||||||
|
try {
|
||||||
|
const sp = spForSite(type.siteUrl as string, context);
|
||||||
|
const me = await sp.web.currentUser.select('Id')();
|
||||||
|
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
|
||||||
|
|
||||||
|
const rows = (await sp.web
|
||||||
|
.getList(type.listId as string)
|
||||||
|
.items.filter(`AuthorId eq ${me.Id} and Modified ge '${cutoffIso}'`)
|
||||||
|
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
||||||
|
.expand('Author')
|
||||||
|
.top(OWN_REST_TOP)()) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
return rows.map((r) =>
|
||||||
|
toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own')
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`[Текущие заявки] «${type.name}»: не удалось прочитать недавно изменённые заявки:`,
|
||||||
|
(e as Error).message
|
||||||
|
);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -601,12 +731,153 @@ export async function searchOwnRequests(
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const totalRows = results.TotalRows || 0;
|
||||||
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.
|
||||||
const rows = entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r);
|
const rows = entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r);
|
||||||
|
|
||||||
return { rows, totalRows: results.TotalRows || 0 };
|
return { rows, totalRows };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Свои заявки с конкретным статусом ЗАЯВКИ (не строки маршрута) — «Доработка»
|
||||||
|
// и фильтр по статусу на вкладке «Инициированные».
|
||||||
|
//
|
||||||
|
// Статус НЕ уходит в KQL: единственная надёжная managed property здесь —
|
||||||
|
// AuthorOWSUSER (стандартное поле автора), а для произвольной Choice-колонки
|
||||||
|
// имя managed property надо либо знать из схемы поиска, либо угадывать по
|
||||||
|
// конвенции — угадали неверно (или она ещё не переиндексирована после
|
||||||
|
// переименования колонки Status → RequestStatus), из-за чего фильтр по
|
||||||
|
// статусу молча возвращал 0. Поэтому здесь Search ищет ТОЛЬКО по автору
|
||||||
|
// (+ опционально текст) — это находит ВСЕ заявки автора вплоть до
|
||||||
|
// KQL_ID_LIMIT, — а отбор по статусу и постраничность делаются в памяти по
|
||||||
|
// уже дочитанным через REST реальным полям (см. searchOwnAll/loadByIds).
|
||||||
|
// Тот же принцип, что и в filterPendingByQuery: Search сужает набор по
|
||||||
|
// надёжному полю, остальное — на клиенте.
|
||||||
|
export async function getOwnRequestsByStatus(
|
||||||
|
context: WebPartContext,
|
||||||
|
types: IRequestType[],
|
||||||
|
status: string,
|
||||||
|
page: number,
|
||||||
|
query: string = ''
|
||||||
|
): Promise<IOwnRequestsPage> {
|
||||||
|
const all = await searchOwnAll(context, types, query);
|
||||||
|
const searchFiltered = all.filter((r) => r.status === status);
|
||||||
|
|
||||||
|
// Довесок по недавним изменениям — только на первой странице и только без
|
||||||
|
// текстового поиска (см. getOwnRequests: тот же принцип — довесок относится
|
||||||
|
// к «показать всё, свежее по дате», а не к результату полнотекстового поиска).
|
||||||
|
let merged = searchFiltered;
|
||||||
|
if (page === 1 && !query.trim()) {
|
||||||
|
const freshRows = (await Promise.all(types.map((t) => getRecentlyChangedOwnRowsForType(context, t))))
|
||||||
|
.reduce((acc, r) => acc.concat(r), [] as IMyRequest[])
|
||||||
|
.filter((r) => r.status === status);
|
||||||
|
|
||||||
|
const seen = new Set(searchFiltered.map((r) => `${r.requestTypeId}-${r.id}`));
|
||||||
|
const onlyFresh = freshRows.filter((r) => !seen.has(`${r.requestTypeId}-${r.id}`));
|
||||||
|
merged = onlyFresh.concat(searchFiltered);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = merged.sort((a, b) => b.created.localeCompare(a.created));
|
||||||
|
return {
|
||||||
|
rows: sorted.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||||
|
totalRows: sorted.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Список согласования по типу заявки: Title списка данных → Title списка
|
||||||
|
// согласования по конвенции имён (та же логика, что в indexApprovalsForType).
|
||||||
|
// undefined — список заведён не по конвенции, маршрута для него не найти.
|
||||||
|
async function approvalListFor(
|
||||||
|
context: WebPartContext,
|
||||||
|
type: IRequestType
|
||||||
|
): Promise<IList | undefined> {
|
||||||
|
const sp = spForSite(type.siteUrl as string, context);
|
||||||
|
const info = (await sp.web.getList(type.listId as string).select('Title')()) as { Title?: string };
|
||||||
|
const dataTitle = info.Title || '';
|
||||||
|
if (!DATA_LIST_SUFFIX.test(dataTitle)) return undefined;
|
||||||
|
return sp.web.lists.getByTitle(`${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сколько строк маршрута максимум забираем за раз при дочитывании комментариев:
|
||||||
|
// на странице ≤20 заявок, у каждой обычно единицы шагов — запас многократный.
|
||||||
|
const DECISION_COMMENT_ROW_LIMIT = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проставляет decisionComment — комментарий согласующего к его решению по
|
||||||
|
* заявке: причину возврата на доработку либо отклонения (что именно искать,
|
||||||
|
* задаёт decisionStatus). Комментарий хранится в СТРОКЕ МАРШРУТА (колонка
|
||||||
|
* Comment списка «— согласование»), а не в самой заявке, поэтому его нельзя
|
||||||
|
* получить тем же запросом, что и список: нужен отдельный проход по спискам
|
||||||
|
* согласования.
|
||||||
|
*
|
||||||
|
* Одного статуса достаточно, чтобы найти нужную строку, и для «своих
|
||||||
|
* возвращённых» (вернул кто-то другой), и для «отклонённых мной»: и возврат, и
|
||||||
|
* отклонение останавливают маршрут, поэтому такая строка по заявке одна.
|
||||||
|
*
|
||||||
|
* Вызывается ТОЛЬКО там, где причина показывается (см. RequestsTableView) —
|
||||||
|
* счётчику в меню, например, она не нужна, и лишние запросы ему ни к чему.
|
||||||
|
* Мутирует переданные строки — как и hydrateAuthors рядом.
|
||||||
|
*/
|
||||||
|
export async function hydrateDecisionComments(
|
||||||
|
context: WebPartContext,
|
||||||
|
types: IRequestType[],
|
||||||
|
rows: IMyRequest[],
|
||||||
|
decisionStatus: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
|
||||||
|
const typeById = new Map<number, IRequestType>();
|
||||||
|
types.forEach((t) => typeById.set(t.id, t));
|
||||||
|
|
||||||
|
const rowsByType = new Map<number, IMyRequest[]>();
|
||||||
|
rows.forEach((r) => {
|
||||||
|
const list = rowsByType.get(r.requestTypeId) || [];
|
||||||
|
list.push(r);
|
||||||
|
rowsByType.set(r.requestTypeId, list);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(rowsByType.entries()).map(async ([requestTypeId, typeRows]) => {
|
||||||
|
const type = typeById.get(requestTypeId);
|
||||||
|
if (!type) return;
|
||||||
|
try {
|
||||||
|
const approvalList = await approvalListFor(context, type);
|
||||||
|
if (!approvalList) return;
|
||||||
|
|
||||||
|
// Фильтруем только по TicketId (числовое поле), статус сравниваем в JS:
|
||||||
|
// кириллица в $filter по Choice-колонке молча возвращает 0 строк —
|
||||||
|
// ровно та же причина, что и в indexApprovalsForType.
|
||||||
|
const stepRows = (await approvalList.items
|
||||||
|
.filter(typeRows.map((r) => `TicketId eq ${r.id}`).join(' or '))
|
||||||
|
.select('TicketId', 'ApprovalStatus', 'Comment')
|
||||||
|
.top(DECISION_COMMENT_ROW_LIMIT)()) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
const commentByTicket = new Map<number, string>();
|
||||||
|
stepRows.forEach((r) => {
|
||||||
|
if (r.ApprovalStatus !== decisionStatus) return;
|
||||||
|
const ticketId = r.TicketId as number | undefined;
|
||||||
|
const comment = ((r.Comment as string) || '').trim();
|
||||||
|
// Такая строка по заявке одна (и возврат, и отклонение останавливают
|
||||||
|
// маршрут, а при переотправке он переписывается целиком — см.
|
||||||
|
// writeRoute), но если их вдруг несколько, берём первую НЕПУСТУЮ,
|
||||||
|
// а не последнюю пустую.
|
||||||
|
if (ticketId && comment && !commentByTicket.has(ticketId)) {
|
||||||
|
commentByTicket.set(ticketId, comment);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
typeRows.forEach((r) => {
|
||||||
|
r.decisionComment = commentByTicket.get(r.id);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`[Текущие заявки] «${type.name}»: не удалось прочитать комментарий к решению:`,
|
||||||
|
(e as Error).message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// «На согласовании»: список заявок уже отобран по строкам согласования
|
// «На согласовании»: список заявок уже отобран по строкам согласования
|
||||||
@@ -672,7 +943,7 @@ export interface ISearchPage {
|
|||||||
|
|
||||||
// Как searchOwnRequests, но без $skip/$top — сразу весь найденный срез (до
|
// Как 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
|
||||||
@@ -702,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 };
|
||||||
@@ -724,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(
|
||||||
@@ -746,7 +1021,7 @@ function toMyRequest(
|
|||||||
return {
|
return {
|
||||||
id: r.Id as number,
|
id: r.Id as number,
|
||||||
title: (r.Title as string) || '',
|
title: (r.Title as string) || '',
|
||||||
status: (r.Status as string) || '',
|
status: (r.RequestStatus as string) || '',
|
||||||
authorEmail: author?.EMail || '',
|
authorEmail: author?.EMail || '',
|
||||||
authorName: author?.Title || '',
|
authorName: author?.Title || '',
|
||||||
created: (r.Created as string) || '',
|
created: (r.Created as string) || '',
|
||||||
|
|||||||
@@ -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(';')}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"dataListTitle": "Прием на работу — согласование",
|
"dataListTitle": "Прием на работу — согласование",
|
||||||
"columns": [
|
"columns": [
|
||||||
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
|
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||||
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] },
|
{ "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] },
|
||||||
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" },
|
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" },
|
||||||
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
||||||
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export async function loadHireRequest(itemId: number): Promise<ILoadedRequest> {
|
|||||||
const item = (await sp.web.lists
|
const item = (await sp.web.lists
|
||||||
.getByTitle(LIST_TITLE)
|
.getByTitle(LIST_TITLE)
|
||||||
.items.getById(itemId)
|
.items.getById(itemId)
|
||||||
.select(...SCALAR_FIELDS, 'Status', 'RequestUniqueCode')()) as Record<string, unknown>;
|
.select(...SCALAR_FIELDS, 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>;
|
||||||
|
|
||||||
const data: IFormData = { ...EMPTY_DATA };
|
const data: IFormData = { ...EMPTY_DATA };
|
||||||
const writable = data as unknown as Record<string, string>;
|
const writable = data as unknown as Record<string, string>;
|
||||||
@@ -39,5 +39,5 @@ export async function loadHireRequest(itemId: number): Promise<ILoadedRequest> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { data, status: (item.Status as string) || '', code: (item.RequestUniqueCode as string) || '' };
|
return { data, status: (item.RequestStatus as string) || '', code: (item.RequestUniqueCode as string) || '' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"dataListTitle": "Прием на работу — данные",
|
"dataListTitle": "Прием на работу — данные",
|
||||||
"columns": [
|
"columns": [
|
||||||
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
|
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
|
||||||
{ "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
||||||
|
|
||||||
{ "internalName": "Phone", "title": "Телефон", "type": "Text" },
|
{ "internalName": "Phone", "title": "Телефон", "type": "Text" },
|
||||||
{ "internalName": "HireType", "title": "Тип приёма", "type": "Choice", "choices": ["Новый сотрудник", "Вместо уволенного"] },
|
{ "internalName": "HireType", "title": "Тип приёма", "type": "Choice", "choices": ["Новый сотрудник", "Вместо уволенного"] },
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function buildTitle(data: IFormData): string {
|
|||||||
|
|
||||||
/** Поля элемента списка данных из модели формы (текст/выбор/даты/числа). */
|
/** Поля элемента списка данных из модели формы (текст/выбор/даты/числа). */
|
||||||
function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
||||||
const item: Record<string, unknown> = { Title: buildTitle(data), Status: status };
|
const item: Record<string, unknown> = { Title: buildTitle(data), RequestStatus: status };
|
||||||
|
|
||||||
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
|
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
|
||||||
const value = data[key];
|
const value = data[key];
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"dataListTitle": "Организация рабочего места — согласование",
|
"dataListTitle": "Организация рабочего места — согласование",
|
||||||
"columns": [
|
"columns": [
|
||||||
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
|
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||||
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] },
|
{ "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] },
|
||||||
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Организация рабочего места — данные", "lookupShowField": "Title" },
|
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Организация рабочего места — данные", "lookupShowField": "Title" },
|
||||||
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
||||||
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export async function loadWorkplaceRequest(itemId: number): Promise<ILoadedReque
|
|||||||
const item = (await getSP()
|
const item = (await getSP()
|
||||||
.web.lists.getByTitle(LIST_TITLE)
|
.web.lists.getByTitle(LIST_TITLE)
|
||||||
.items.getById(itemId)
|
.items.getById(itemId)
|
||||||
.select(...SCALAR_FIELDS, 'NeedsEquipment', 'Status', 'RequestUniqueCode')()) as Record<string, unknown>;
|
.select(...SCALAR_FIELDS, 'NeedsEquipment', 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>;
|
||||||
|
|
||||||
const data: IFormData = { ...EMPTY_DATA };
|
const data: IFormData = { ...EMPTY_DATA };
|
||||||
const writable = data as unknown as Record<string, string>;
|
const writable = data as unknown as Record<string, string>;
|
||||||
@@ -38,5 +38,5 @@ export async function loadWorkplaceRequest(itemId: number): Promise<ILoadedReque
|
|||||||
|
|
||||||
data.NeedsEquipment = item.NeedsEquipment === true;
|
data.NeedsEquipment = item.NeedsEquipment === true;
|
||||||
|
|
||||||
return { data, status: (item.Status as string) || '', code: (item.RequestUniqueCode as string) || '' };
|
return { data, status: (item.RequestStatus as string) || '', code: (item.RequestUniqueCode as string) || '' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"dataListTitle": "Организация рабочего места — данные",
|
"dataListTitle": "Организация рабочего места — данные",
|
||||||
"columns": [
|
"columns": [
|
||||||
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
|
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
|
||||||
{ "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
||||||
|
|
||||||
{ "internalName": "FullName", "title": "Ф.И.О.", "type": "Text", "required": true },
|
{ "internalName": "FullName", "title": "Ф.И.О.", "type": "Text", "required": true },
|
||||||
{ "internalName": "NameLatin", "title": "Фамилия и имя (латиница)", "type": "Text" },
|
{ "internalName": "NameLatin", "title": "Фамилия и имя (латиница)", "type": "Text" },
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function buildTitle(data: IFormData): string {
|
|||||||
|
|
||||||
/** Поля элемента списка данных из модели формы. */
|
/** Поля элемента списка данных из модели формы. */
|
||||||
function buildItem(data: IFormData, status: string, sourceHireId?: number): Record<string, unknown> {
|
function buildItem(data: IFormData, status: string, sourceHireId?: number): Record<string, unknown> {
|
||||||
const item: Record<string, unknown> = { Title: buildTitle(data), Status: status };
|
const item: Record<string, unknown> = { Title: buildTitle(data), RequestStatus: status };
|
||||||
|
|
||||||
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
|
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
|
||||||
const value = data[key];
|
const value = data[key];
|
||||||
|
|||||||
@@ -12,12 +12,18 @@ import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
|
|||||||
|
|
||||||
// Внутренние имена колонок списка согласования (создаются провижинингом).
|
// Внутренние имена колонок списка согласования (создаются провижинингом).
|
||||||
const FIELDS = {
|
const FIELDS = {
|
||||||
// Внутренние имена с префиксом группы колонок сайта (TansuSite) и «Request» —
|
// Внутренние имена с префиксом группы колонок сайта (TansuSite) там, где он
|
||||||
// так они заведены в SharePoint. Из них SharePoint выводит имена managed
|
// есть, — так они заведены в SharePoint. Из них SharePoint выводит имена
|
||||||
// properties для поиска: TansuSiteApproverOWSUSER и RequestStatusOWSCHCS
|
// managed properties для поиска: TansuSiteApproverOWSUSER и
|
||||||
// (см. MP_APPROVER/MP_STATUS в features/myRequests/myRequests.service.ts).
|
// ApprovalStatusOWSCHCS (см. MP_APPROVER/MP_STATUS в
|
||||||
|
// features/myRequests/myRequests.service.ts).
|
||||||
|
//
|
||||||
|
// ВАЖНО: колонка называется ApprovalStatus, а не Status и не RequestStatus —
|
||||||
|
// это статус СТРОКИ маршрута (список «— согласование»); статус ЗАЯВКИ в
|
||||||
|
// списке «— данных» лежит в отдельной колонке RequestStatus (см.
|
||||||
|
// models/statuses.ts — там оба набора значений сведены в одну картину).
|
||||||
approver: 'TansuSiteApprover', // «Согласующий» (User) → TansuSiteApproverId
|
approver: 'TansuSiteApprover', // «Согласующий» (User) → TansuSiteApproverId
|
||||||
status: 'RequestStatus', // «Статус» (Choice)
|
status: 'ApprovalStatus', // «Статус» (Choice) строки маршрута
|
||||||
ticket: 'Ticket', // «Заявка» (Lookup) → TicketId
|
ticket: 'Ticket', // «Заявка» (Lookup) → TicketId
|
||||||
date: 'Date', // «Дата» (DateTime)
|
date: 'Date', // «Дата» (DateTime)
|
||||||
comment: 'Comment', // «Комментарий» (Text)
|
comment: 'Comment', // «Комментарий» (Text)
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export class ApproverService {
|
|||||||
|
|
||||||
private async updateRequestStatus(id: number, status: string): Promise<void> {
|
private async updateRequestStatus(id: number, status: string): Promise<void> {
|
||||||
await getSP().web.lists.getByTitle(this.dataListTitle).items.getById(id).update({
|
await getSP().web.lists.getByTitle(this.dataListTitle).items.getById(id).update({
|
||||||
Status: status,
|
RequestStatus: status,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,16 +45,21 @@ export function isEditableStatus(status: string | undefined): boolean {
|
|||||||
|
|
||||||
// Наборов значений ДВА, и они не совпадают — это разные сущности:
|
// Наборов значений ДВА, и они не совпадают — это разные сущности:
|
||||||
//
|
//
|
||||||
// 1) статус ЗАЯВКИ — колонка Status в списке «… — данные». Пишется формой
|
// 1) статус ЗАЯВКИ — колонка RequestStatus в списке «… — данные». Пишется
|
||||||
// (Черновик/На согласовании) и computeRequestStatus после каждого решения.
|
// формой (Черновик/На согласовании) и computeRequestStatus после каждого
|
||||||
// «Ждёт очереди»/«На исполнении»/«Выполнено» здесь не бывают: это состояния
|
// решения. «Ждёт очереди»/«На исполнении»/«Выполнено» здесь не бывают: это
|
||||||
// отдельного шага маршрута, а не заявки целиком.
|
// состояния отдельного шага маршрута, а не заявки целиком.
|
||||||
//
|
//
|
||||||
// 2) статус СТРОКИ МАРШРУТА — колонка RequestStatus в списке «… — согласование».
|
// 2) статус СТРОКИ МАРШРУТА — колонка ApprovalStatus в списке «… — согласование».
|
||||||
// Пишется createSteps/activateNextQueued/setDecision. «Выполнение»/«Закрыта»
|
// Пишется createSteps/activateNextQueued/setDecision. «Выполнение»/«Закрыта»
|
||||||
// здесь не бывают: это агрегатные состояния заявки, у отдельного шага
|
// здесь не бывают: это агрегатные состояния заявки, у отдельного шага
|
||||||
// смысла не имеют.
|
// смысла не имеют.
|
||||||
//
|
//
|
||||||
|
// Обе колонки переименованы (были Status в списке данных и RequestStatus в
|
||||||
|
// списке согласования соответственно) — переименование внутреннего имени в
|
||||||
|
// SharePoint не трогает уже сохранённые значения, отдельная миграция данных
|
||||||
|
// не нужна.
|
||||||
|
//
|
||||||
// Держать их в синхроне с forms/*/provisioning.json и forms/*/approval/provisioning.json.
|
// Держать их в синхроне с forms/*/provisioning.json и forms/*/approval/provisioning.json.
|
||||||
export const REQUEST_STATUS_CHOICES: string[] = [
|
export const REQUEST_STATUS_CHOICES: string[] = [
|
||||||
REQUEST_STATUS.draft,
|
REQUEST_STATUS.draft,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { installErrorLogging } from './diagnostics';
|
||||||
|
|
||||||
|
// jsdom не порождает unhandledrejection сам — событие диспатчим руками, как это
|
||||||
|
// сделал бы браузер. Конструктора PromiseRejectionEvent в jsdom нет, поэтому
|
||||||
|
// собираем обычное Event и дописываем reason (слушатель читает только его).
|
||||||
|
function emitRejection(reason: unknown): void {
|
||||||
|
const event = new Event('unhandledrejection') as Event & { reason?: unknown };
|
||||||
|
event.reason = reason;
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('installErrorLogging', () => {
|
||||||
|
let errors: unknown[][];
|
||||||
|
let spy: jest.SpyInstance;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
errors = [];
|
||||||
|
spy = jest.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
|
||||||
|
errors.push(args);
|
||||||
|
});
|
||||||
|
installErrorLogging();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => spy.mockRestore());
|
||||||
|
|
||||||
|
it('печатает сообщение и стек обычной ошибки', () => {
|
||||||
|
emitRejection(new Error('Список недоступен'));
|
||||||
|
|
||||||
|
const text = errors.map((a) => a.join(' ')).join('\n');
|
||||||
|
expect(text).toContain('Список недоступен');
|
||||||
|
expect(text).toContain('Стек');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не теряет причину, если это не Error, а объект (типичный отказ PnPjs)', () => {
|
||||||
|
emitRejection({ status: 403, message: 'Access denied' });
|
||||||
|
|
||||||
|
const text = errors.map((a) => a.map((x) => JSON.stringify(x)).join(' ')).join('\n');
|
||||||
|
expect(text).toContain('403');
|
||||||
|
expect(text).toContain('Access denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не падает на причине с циклической ссылкой', () => {
|
||||||
|
const cyclic: Record<string, unknown> = { name: 'зацикленное' };
|
||||||
|
cyclic.self = cyclic;
|
||||||
|
|
||||||
|
expect(() => emitRejection(cyclic)).not.toThrow();
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('строка-причина попадает в лог как есть', () => {
|
||||||
|
emitRejection('просто текст');
|
||||||
|
expect(errors.map((a) => a.join(' ')).join('\n')).toContain('просто текст');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('повторный вызов не вешает второй слушатель — ошибка не двоится', () => {
|
||||||
|
installErrorLogging();
|
||||||
|
installErrorLogging();
|
||||||
|
|
||||||
|
emitRejection(new Error('однократно'));
|
||||||
|
|
||||||
|
const count = errors.filter((a) => String(a[0]).indexOf('Необработанная ошибка') !== -1).length;
|
||||||
|
expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Читаемый лог необработанных отклонений промисов.
|
||||||
|
//
|
||||||
|
// Зачем: SPFx вешает на window свой обработчик unhandledrejection, и в консоль
|
||||||
|
// попадает только его собственный стек внутри минифицированного бандла —
|
||||||
|
// примерно такой:
|
||||||
|
//
|
||||||
|
// Error
|
||||||
|
// at vp (...request-portal-web-part_<hash>.js:9:397946)
|
||||||
|
// at t.onUnhandledRejection (...)
|
||||||
|
//
|
||||||
|
// Ни причины, ни места, где промис отклонился, там нет: SPFx логирует САМ факт,
|
||||||
|
// а не `reason`. Поэтому вешаем свой слушатель — он срабатывает наравне с
|
||||||
|
// SPFx-овским и печатает то, что действительно нужно: сообщение, стек и сам
|
||||||
|
// объект причины (у ошибок PnPjs/SharePoint внутри лежит ответ сервера).
|
||||||
|
//
|
||||||
|
// Ничего не подавляем (preventDefault не зовём) — поведение портала не
|
||||||
|
// меняется, добавляется только диагностика.
|
||||||
|
|
||||||
|
const PREFIX = '[Портал заявок]';
|
||||||
|
|
||||||
|
// Слушатель вешается один раз на страницу: на странице может быть несколько
|
||||||
|
// экземпляров веб-части, и каждый вызвал бы install() в onInit — без флага
|
||||||
|
// одна и та же ошибка печаталась бы столько раз, сколько их размещено.
|
||||||
|
let installed = false;
|
||||||
|
|
||||||
|
// Причиной отклонения может быть что угодно, не только Error — у PnPjs это
|
||||||
|
// бывает объект с полями ответа. Разбираем по факту, а не по ожиданию.
|
||||||
|
function describeReason(reason: unknown): string {
|
||||||
|
if (reason instanceof Error) return `${reason.name}: ${reason.message}`;
|
||||||
|
if (typeof reason === 'string') return reason;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(reason);
|
||||||
|
} catch {
|
||||||
|
// Циклические ссылки — тогда хотя бы тип.
|
||||||
|
return Object.prototype.toString.call(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installErrorLogging(): void {
|
||||||
|
if (installed) return;
|
||||||
|
installed = true;
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
|
||||||
|
const reason: unknown = e.reason;
|
||||||
|
console.error(`${PREFIX} Необработанная ошибка: ${describeReason(reason)}`);
|
||||||
|
// Стек и объект — отдельными аргументами: так консоль браузера даёт
|
||||||
|
// раскрыть объект и перейти по стеку, а не показывает «[object Object]».
|
||||||
|
if (reason instanceof Error && reason.stack) console.error(`${PREFIX} Стек:`, reason.stack);
|
||||||
|
console.error(`${PREFIX} Причина целиком:`, reason);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,18 +1,37 @@
|
|||||||
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 задаёт раздел бокового меню', () => {
|
||||||
expect(parseRoute('?rpnav=approvals').nav).toBe('approvals');
|
expect(parseRoute('?rpnav=approvals').nav).toBe('approvals');
|
||||||
|
expect(parseRoute('?rpnav=execution').nav).toBe('execution');
|
||||||
|
expect(parseRoute('?rpnav=rework').nav).toBe('rework');
|
||||||
expect(parseRoute('?rpnav=own').nav).toBe('own');
|
expect(parseRoute('?rpnav=own').nav).toBe('own');
|
||||||
expect(parseRoute('?rpnav=search').nav).toBe('search');
|
expect(parseRoute('?rpnav=search').nav).toBe('search');
|
||||||
});
|
});
|
||||||
@@ -27,6 +46,8 @@ describe('parseRoute', () => {
|
|||||||
nav: 'new',
|
nav: 'new',
|
||||||
formKey: 'hire',
|
formKey: 'hire',
|
||||||
itemId: 42,
|
itemId: 42,
|
||||||
|
page: 1,
|
||||||
|
query: '',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,6 +61,8 @@ describe('parseRoute', () => {
|
|||||||
nav: 'new',
|
nav: 'new',
|
||||||
formKey: 'hire',
|
formKey: 'hire',
|
||||||
itemId: 7,
|
itemId: 7,
|
||||||
|
page: 1,
|
||||||
|
query: '',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,6 +96,351 @@ describe('parseRoute', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('подвкладки и фильтр «Моих заявок»', () => {
|
||||||
|
it('без rptab — подвкладка по умолчанию', () => {
|
||||||
|
expect(parseRoute('?rpnav=own').ownTab).toBe('created');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rptab задаёт подвкладку', () => {
|
||||||
|
expect(parseRoute('?rpnav=own&rptab=approved').ownTab).toBe('approved');
|
||||||
|
expect(parseRoute('?rpnav=own&rptab=rejected').ownTab).toBe('rejected');
|
||||||
|
expect(parseRoute('?rpnav=own&rptab=executed').ownTab).toBe('executed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('незнакомый rptab откатывается на подвкладку по умолчанию', () => {
|
||||||
|
expect(parseRoute('?rpnav=own&rptab=bogus').ownTab).toBe('created');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('вне «Моих заявок» подвкладка не читается — чужой rptab туда не протекает', () => {
|
||||||
|
expect(parseRoute('?rpnav=approvals&rptab=approved').ownTab).toBeUndefined();
|
||||||
|
expect(parseRoute('?rpnav=search&rptab=approved').ownTab).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('фильтр по статусу действует только на вкладке «Инициированные»', () => {
|
||||||
|
expect(parseRoute('?rpnav=own&rpstatus=Черновик').ownStatus).toBe('Черновик');
|
||||||
|
// У вкладок с решениями отбор задаёт сама вкладка — фильтр там бессмыслен.
|
||||||
|
expect(parseRoute('?rpnav=own&rptab=approved&rpstatus=Черновик').ownStatus).toBeUndefined();
|
||||||
|
expect(parseRoute('?rpnav=approvals&rpstatus=Черновик').ownStatus).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('значения по умолчанию не засоряют адрес', () => {
|
||||||
|
expect(routeUrl({ mode: 'submit', nav: 'own', ownTab: 'created' })).not.toContain('rptab');
|
||||||
|
expect(routeUrl({ mode: 'submit', nav: 'own' })).not.toContain('rptab');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('непустая подвкладка и фильтр попадают в адрес', () => {
|
||||||
|
expect(routeUrl({ mode: 'submit', nav: 'own', ownTab: 'rejected' })).toContain('rptab=rejected');
|
||||||
|
expect(routeUrl({ mode: 'submit', nav: 'own', ownTab: 'created', ownStatus: 'Черновик' })).toContain('rpstatus=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('фильтр не пишется в адрес вместе с подвкладкой решений', () => {
|
||||||
|
const url = routeUrl({ mode: 'submit', nav: 'own', ownTab: 'approved', ownStatus: 'Черновик' });
|
||||||
|
expect(url).toContain('rptab=approved');
|
||||||
|
expect(url).not.toContain('rpstatus');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('уход из «Моих заявок» убирает подвкладку и фильтр из адреса', () => {
|
||||||
|
navigate({ mode: 'submit', nav: 'own', ownTab: 'rejected' });
|
||||||
|
navigate({ mode: 'submit', nav: 'approvals' });
|
||||||
|
expect(window.location.search).not.toContain('rptab');
|
||||||
|
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 для подвкладки решений', () => {
|
||||||
|
navigate({ mode: 'submit', nav: 'own', ownTab: 'executed' });
|
||||||
|
const route = parseRoute(window.location.search);
|
||||||
|
expect(route.nav).toBe('own');
|
||||||
|
expect(route.ownTab).toBe('executed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('routeUrl', () => {
|
describe('routeUrl', () => {
|
||||||
it('раздел по умолчанию не засоряет адрес параметром', () => {
|
it('раздел по умолчанию не засоряет адрес параметром', () => {
|
||||||
expect(routeUrl({ mode: 'submit', nav: 'new' })).toBe('/');
|
expect(routeUrl({ mode: 'submit', nav: 'new' })).toBe('/');
|
||||||
@@ -103,9 +471,26 @@ describe('navigate', () => {
|
|||||||
nav: 'own',
|
nav: 'own',
|
||||||
formKey: 'hire',
|
formKey: 'hire',
|
||||||
itemId: 42,
|
itemId: 42,
|
||||||
|
// Подвкладка «Моих заявок» всегда определена внутри раздела — в адресе её
|
||||||
|
// нет только потому, что это значение по умолчанию.
|
||||||
|
ownTab: 'created',
|
||||||
|
ownStatus: undefined,
|
||||||
|
page: 1,
|
||||||
|
query: '',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('подвкладка и фильтр «Моих заявок» переживают открытие и закрытие заявки', () => {
|
||||||
|
navigate({ mode: 'submit', nav: 'own', ownTab: 'created', ownStatus: 'Черновик' });
|
||||||
|
navigate({ mode: 'approve', nav: 'own', formKey: 'hire', itemId: 42, ownTab: 'created', ownStatus: 'Черновик' });
|
||||||
|
|
||||||
|
const opened = parseRoute(window.location.search);
|
||||||
|
expect(opened.ownStatus).toBe('Черновик');
|
||||||
|
|
||||||
|
navigate({ mode: 'submit', nav: 'own', ownTab: opened.ownTab, ownStatus: opened.ownStatus });
|
||||||
|
expect(parseRoute(window.location.search).ownStatus).toBe('Черновик');
|
||||||
|
});
|
||||||
|
|
||||||
it('round-trip для создания производной заявки', () => {
|
it('round-trip для создания производной заявки', () => {
|
||||||
navigate({ mode: 'create', nav: 'new', formKey: 'workplace', sourceId: 15 });
|
navigate({ mode: 'create', nav: 'new', formKey: 'workplace', sourceId: 15 });
|
||||||
expect(parseRoute(window.location.search)).toEqual({
|
expect(parseRoute(window.location.search)).toEqual({
|
||||||
|
|||||||
@@ -24,8 +24,25 @@ import * as React from 'react';
|
|||||||
// отклонённую) на редактирование в той же выезжающей панели, что и просмотр.
|
// отклонённую) на редактирование в той же выезжающей панели, что и просмотр.
|
||||||
export type PortalMode = 'submit' | 'approve' | 'create' | 'edit';
|
export type PortalMode = 'submit' | 'approve' | 'create' | 'edit';
|
||||||
|
|
||||||
/** Раздел бокового меню. */
|
/**
|
||||||
export type NavSection = 'new' | 'approvals' | 'own' | 'search';
|
* Раздел бокового меню.
|
||||||
|
*
|
||||||
|
* Три раздела — очереди действий текущего пользователя, и они НЕ пересекаются
|
||||||
|
* (см. ApprovalQueueKind в myRequests.service.ts):
|
||||||
|
* 'approvals' — ждут его согласования (статус строки «На согласовании»),
|
||||||
|
* 'execution' — ждут его исполнения (статус строки «На исполнении»),
|
||||||
|
* 'rework' — ЕГО заявки, которые вернули ему на доработку (статус ЗАЯВКИ
|
||||||
|
* «Возвращена на доработку»); в отличие от первых двух смотрит
|
||||||
|
* на список данных по автору, а не на строки маршрута.
|
||||||
|
*/
|
||||||
|
export type NavSection = 'new' | 'approvals' | 'execution' | 'rework' | 'own' | 'search';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подвкладка раздела «Мои заявки». 'created' — заявки, где пользователь автор
|
||||||
|
* (единственная, где есть фильтр по статусу); остальные — история его решений
|
||||||
|
* как участника чужих маршрутов.
|
||||||
|
*/
|
||||||
|
export type OwnTab = 'created' | 'approved' | 'rejected' | 'executed';
|
||||||
|
|
||||||
export interface IRoute {
|
export interface IRoute {
|
||||||
mode: PortalMode;
|
mode: PortalMode;
|
||||||
@@ -37,15 +54,207 @@ export interface IRoute {
|
|||||||
itemId?: number;
|
itemId?: number;
|
||||||
/** Id исходной заявки для предзаполнения — для 'create' (напр. орг. места на основе прием). */
|
/** Id исходной заявки для предзаполнения — для 'create' (напр. орг. места на основе прием). */
|
||||||
sourceId?: number;
|
sourceId?: number;
|
||||||
|
/** Подвкладка «Моих заявок»; задана только при nav='own'. */
|
||||||
|
ownTab?: OwnTab;
|
||||||
|
/** Фильтр по статусу заявки; задан только при nav='own' и ownTab='created'. */
|
||||||
|
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 (raw === 'approvals' || raw === 'own' || raw === 'search') return raw;
|
if (
|
||||||
|
raw === 'approvals' ||
|
||||||
|
raw === 'execution' ||
|
||||||
|
raw === 'rework' ||
|
||||||
|
raw === 'own' ||
|
||||||
|
raw === 'search'
|
||||||
|
) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
return 'new';
|
return 'new';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseOwnTab(raw: string | undefined): OwnTab {
|
||||||
|
if (raw === 'approved' || raw === 'rejected' || raw === 'executed' || raw === 'created') return raw;
|
||||||
|
return readStoredOwnTab();
|
||||||
|
}
|
||||||
|
|
||||||
/** Разобрать строку запроса (по умолчанию — из адреса страницы) в маршрут. */
|
/** Разобрать строку запроса (по умолчанию — из адреса страницы) в маршрут. */
|
||||||
export function parseRoute(search: string = window.location.search): IRoute {
|
export function parseRoute(search: string = window.location.search): IRoute {
|
||||||
const params = new URLSearchParams(search);
|
const params = new URLSearchParams(search);
|
||||||
@@ -57,19 +266,39 @@ export function parseRoute(search: string = window.location.search): IRoute {
|
|||||||
const sourceRaw = params.get('rpsource');
|
const sourceRaw = params.get('rpsource');
|
||||||
const sourceId = sourceRaw ? Number(sourceRaw) : undefined;
|
const sourceId = sourceRaw ? Number(sourceRaw) : undefined;
|
||||||
|
|
||||||
|
// Подвкладка и фильтр — состояние раздела «Мои заявки», поэтому вне его не
|
||||||
|
// читаются вовсе (иначе чужой rptab из ссылки «протёк» бы в другой раздел).
|
||||||
|
// Фильтр по статусу живёт только на вкладке «Инициированные»: у вкладок с
|
||||||
|
// решениями отбор задаёт сама вкладка.
|
||||||
|
const ownTab = nav === 'own' ? parseOwnTab(params.get('rptab') ?? 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 };
|
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 };
|
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 };
|
return { mode: 'submit', nav, ownTab, ownStatus, page, query };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Собрать query-строку маршрута, сохранив посторонние параметры страницы.
|
// Собрать query-строку маршрута, сохранив посторонние параметры страницы.
|
||||||
@@ -80,10 +309,24 @@ function buildQuery(route: IRoute): string {
|
|||||||
params.delete('rptype');
|
params.delete('rptype');
|
||||||
params.delete('rpid');
|
params.delete('rpid');
|
||||||
params.delete('rpsource');
|
params.delete('rpsource');
|
||||||
|
params.delete('rptab');
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
// Значения по умолчанию в адрес не пишем — чтобы обычный переход на «Мои
|
||||||
|
// заявки» давал чистую ссылку, а не ?rpnav=own&rptab=created.
|
||||||
|
if (route.nav === 'own') {
|
||||||
|
const ownTab = route.ownTab ?? 'created';
|
||||||
|
if (ownTab !== 'created') params.set('rptab', ownTab);
|
||||||
|
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);
|
||||||
@@ -101,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];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user