4 Commits

Author SHA1 Message Date
gorbenko.pavel 79e44e01be теперь можно пройти по заявку в мои заявки 2026-08-04 12:09:47 +05:00
gorbenko.pavel 29a91a5654 Merge remote-tracking branch 'origin/main' into left-panel
# Conflicts:
#	src/webparts/requestPortal/components/RequestPortal.tsx
2026-08-04 11:09:15 +05:00
gorbenko.pavel 63f78fc862 Merge remote-tracking branch 'origin/main' into left-panel 2026-08-03 18:00:51 +05:00
gorbenko.pavel 4b97745fec левая панель готова 2026-08-03 18:00:19 +05:00
13 changed files with 539 additions and 49 deletions
+3
View File
@@ -8,3 +8,6 @@ jest-output/
*.sppkg *.sppkg
.heft/ .heft/
sharepoint/solution/ sharepoint/solution/
# JetBrains IDEA
.idea/
@@ -4,7 +4,6 @@
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
gap: 14px; gap: 14px;
margin-top: $sp-6;
} }
.tile { .tile {
@@ -99,7 +98,6 @@
} }
.empty { .empty {
margin-top: $sp-6;
color: $gray-500; color: $gray-500;
font-size: $fs-sm; font-size: $fs-sm;
} }
@@ -54,6 +54,30 @@
align-self: stretch; align-self: stretch;
} }
.tabsWrap {
margin-bottom: $sp-6;
}
// Возврат из режима согласования (approve) к списку заявок (SubmitFeature).
.backButton {
display: inline-flex;
align-items: center;
gap: $sp-2;
margin-bottom: $sp-4;
padding: 0;
border: 0;
background: transparent;
color: $navy-700;
font-family: $font-sans;
font-size: $fs-sm;
cursor: pointer;
transition: color .18s;
&:hover {
color: $gold-600;
}
}
// На узких экранах панель уходит под формы. // На узких экранах панель уходит под формы.
@media (max-width: 900px) { @media (max-width: 900px) {
.layout { .layout {
@@ -2,42 +2,76 @@ 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 { parseRoute } from '../shared/routing'; import { Icon } from '../shared/ui/Icon/Icon';
import { navigate, useRoute } from '../shared/routing';
import { CategoryTabs } from './CategoryTabs/CategoryTabs';
import { CATEGORIES, DEFAULT_CATEGORY_ID } from '../data/categories';
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 MyRequestsPanel from '../features/myRequests/MyRequestsPanel';
// Оболочка портала. Логики фич тут нет — файл общий, меняется редко. // Оболочка портала. Логики фич тут нет — файл общий, меняется редко.
// Роутинг по URL: mode=approve&type=&id= → фича согласования, иначе — подача. // Роутинг по URL: mode=approve&type=&id= → фича согласования, иначе — подача.
// //
// Панель «Мои заявки» (features/myRequests) готова как компонент, но НЕ смонтирована — // Вкладки категорий рендерятся здесь (а не внутри SubmitFeature), чтобы
// её подключает второй разработчик, когда наполнит. Для этого: // оставаться на экране и в режиме согласования (approve) — раньше режим
// 1) import MyRequestsPanel from '../features/myRequests/MyRequestsPanel'; // согласования подменял всю область main и вкладки пропадали вместе с ней.
// 2) обернуть контент подачи в <div className={styles.layout}> с двумя колонками:
// <main className={styles.main}><SubmitFeature .../></main>
// <div className={styles.side}><MyRequestsPanel context={context} /></div>
// (классы layout/main/side уже есть в RequestPortal.module.scss)
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)
.join(' '); .join(' ');
const route = parseRoute(); const route = useRoute();
const [activeCategoryId, setActiveCategoryId] = React.useState<string>(() => {
if (route.mode === 'create' && route.formKey) {
const found = REQUEST_TYPES.filter((r) => r.id === route.formKey)[0];
if (found) return found.categoryId;
}
return DEFAULT_CATEGORY_ID;
});
return ( return (
<section className={rootClasses}> <section className={rootClasses}>
<IconSprite /> <IconSprite />
{route.mode === 'approve' && route.formKey && route.itemId ? ( <div className={styles.layout}>
<ApproveFeature context={context} formKey={route.formKey} itemId={route.itemId} /> <div className={styles.side}>
) : ( <MyRequestsPanel context={context} />
<SubmitFeature </div>
context={context} <main className={styles.main}>
initialForm={ <div className={styles.tabsWrap}>
route.mode === 'create' && route.formKey && route.sourceId <CategoryTabs
? { formKey: route.formKey, sourceId: route.sourceId } categories={CATEGORIES}
: undefined activeId={activeCategoryId}
} onChange={setActiveCategoryId}
/> />
)} </div>
{route.mode === 'approve' && route.formKey && route.itemId ? (
<>
<button
type="button"
className={styles.backButton}
onClick={() => navigate({ mode: 'submit' })}
>
<Icon name="arrow-left" size="sm" />
<span>К списку заявок</span>
</button>
<ApproveFeature context={context} formKey={route.formKey} itemId={route.itemId} />
</>
) : (
<SubmitFeature
context={context}
activeCategoryId={activeCategoryId}
initialForm={
route.mode === 'create' && route.formKey && route.sourceId
? { formKey: route.formKey, sourceId: route.sourceId }
: undefined
}
/>
)}
</main>
</div>
</section> </section>
); );
}; };
@@ -28,6 +28,121 @@
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: $sp-5; padding: $sp-5;
color: $gray-500;
font-size: $fs-sm; font-size: $fs-sm;
} }
.empty,
.error {
color: $gray-500;
}
.error {
color: $red-500;
}
.group + .group {
margin-top: $sp-5;
}
.groupTitle {
font-family: $font-serif;
font-size: $fs-label;
font-weight: 600;
color: $navy-800;
margin-bottom: $sp-2;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
padding: $sp-2 $sp-3;
border: 1px solid $gray-200;
border-radius: $r-sm;
background: $gray-50;
text-decoration: none;
transition: .16s;
&:hover {
border-color: $gold-400;
background: $gold-50;
}
}
.itemMain {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
overflow: hidden;
}
.itemTitle {
color: $gray-900;
font-size: $fs-sm;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.itemAuthor {
color: $gray-500;
font-size: $fs-caption;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.itemDate {
color: $gray-400;
font-size: $fs-caption;
}
.status {
flex-shrink: 0;
padding: 2px 8px;
border-radius: $r-full;
font-size: $fs-caption;
font-weight: 500;
white-space: nowrap;
}
.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,23 +1,134 @@
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 { getMyRequests } from './myRequests.service';
import { IMyRequest } from './models/IMyRequest';
import { REQUEST_STATUS } from '../../shared/approval';
import { navigate, routeUrl } from '../../shared/routing';
import styles from './MyRequestsPanel.module.scss'; import styles from './MyRequestsPanel.module.scss';
export interface IMyRequestsPanelProps { export interface IMyRequestsPanelProps {
context: WebPartContext; context: WebPartContext;
} }
// Постоянная боковая панель «Мои заявки»: пользователь видит, что он отправлял interface IGroup {
// и согласовывал. Всегда открыта (без кнопки/оверлея). requestTypeName: string;
rows: IMyRequest[];
}
function groupByType(rows: IMyRequest[]): IGroup[] {
const order: string[] = [];
const map: Record<string, IMyRequest[]> = {};
rows.forEach((r) => {
if (!map[r.requestTypeName]) {
map[r.requestTypeName] = [];
order.push(r.requestTypeName);
}
map[r.requestTypeName].push(r);
});
return order.map((name) => ({ requestTypeName: name, rows: map[name] }));
}
function formatDate(iso: string): string {
if (!iso) return '';
const d = new Date(iso);
const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`;
const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`;
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;
}
}
// Постоянная боковая панель «Мои заявки»: заявки, которые создал текущий
// пользователь, по всем типам заявок, известным approval-route API.
// Владелец: второй разработчик. Вся логика/данные — внутри этой папки. // Владелец: второй разработчик. Вся логика/данные — внутри этой папки.
// Общий UI-кит (shared/ui), pnpjs и config можно импортировать, но НЕ менять. // Общий UI-кит (shared/ui), pnpjs и config можно импортировать, но НЕ менять.
const MyRequestsPanel: React.FC<IMyRequestsPanelProps> = (/* { context } */) => { //
// Диагностика по типам заявок (listId/siteUrl/rowCount/error) в UI не выводится —
// см. console.table в devtools при загрузке панели.
const MyRequestsPanel: React.FC<IMyRequestsPanelProps> = ({ context }) => {
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
const [error, setError] = React.useState<string>('');
React.useEffect(() => {
let cancelled = false;
getMyRequests(context)
.then((result) => {
if (cancelled) return;
setRows(result.rows);
console.table(result.debug);
})
.catch((e) => {
if (!cancelled) setError((e as Error).message);
});
return () => {
cancelled = true;
};
}, [context]);
const groups = rows ? groupByType(rows) : [];
return ( return (
<aside className={styles.panel} aria-label="Мои заявки"> <aside className={styles.panel} aria-label="Мои заявки">
<div className={styles.header}> <div className={styles.header}>
<span className={styles.title}>Мои заявки</span> <span className={styles.title}>Мои заявки</span>
</div> </div>
<div className={styles.body}> <div className={styles.body}>
Раздел в разработке: здесь будут заявки пользователя, статусы и согласования. {error && <div className={styles.error}>Не удалось загрузить заявки: {error}</div>}
{!error && rows === undefined && <div className={styles.empty}>Загрузка…</div>}
{!error && rows && rows.length === 0 && (
<div className={styles.empty}>Вы ещё не создавали заявок.</div>
)}
{groups.map((group) => (
<div className={styles.group} key={group.requestTypeName}>
<div className={styles.groupTitle}>{group.requestTypeName}</div>
<ul className={styles.list}>
{group.rows.map((r) => (
<li key={`${r.requestTypeId}-${r.id}`}>
<a
className={styles.item}
href={routeUrl({ mode: 'approve', formKey: r.formKey, itemId: r.id })}
onClick={(e) => {
// Обычный левый клик — SPA-переход (без перезагрузки страницы).
// Ctrl/Cmd/Shift/среднюю кнопку — не трогаем, пусть браузер сам
// откроет в новой вкладке по href.
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
e.preventDefault();
navigate({ mode: 'approve', formKey: r.formKey, itemId: r.id });
}}
>
<div className={styles.itemMain}>
<span className={styles.itemTitle}>{r.title || `#${r.id}`}</span>
<span className={styles.itemAuthor}>
{r.authorEmail || r.authorName || '— автор не определён —'}
</span>
{r.created && <span className={styles.itemDate}>{formatDate(r.created)}</span>}
</div>
<span className={`${styles.status} ${statusClass(r.status)}`}>
{r.status || '—'}
</span>
</a>
</li>
))}
</ul>
</div>
))}
</div> </div>
</aside> </aside>
); );
@@ -0,0 +1,16 @@
// Строка заявки пользователя для боковой панели «Мои заявки».
// Собирается из списка данных SharePoint конкретного типа заявки (см. myRequests.service.ts).
export interface IMyRequest {
id: number;
title: string;
status: string;
authorEmail: string;
authorName: string;
/** Дата создания (ISO-строка, колонка Created). */
created: string;
requestTypeId: number;
requestTypeName: string;
section: string;
/** Ключ формы (rptype в URL режима согласования) — поле pageUrl из approval-route API. */
formKey: string;
}
@@ -0,0 +1,20 @@
import { IMyRequest } from './IMyRequest';
// Диагностика по одному типу заявки — для отладочного блока в панели «Мои заявки».
export interface IMyRequestsTypeDebug {
requestTypeId: number;
requestTypeName: string;
listId?: string;
siteUrl?: string;
/** Тип пропущен: у него нет listId/siteUrl в approval-route API. */
skipped: boolean;
/** Сколько строк текущего пользователя найдено (undefined, если не дошли до запроса). */
rowCount?: number;
/** Текст ошибки, если запрос к списку упал. */
error?: string;
}
export interface IMyRequestsResult {
rows: IMyRequest[];
debug: IMyRequestsTypeDebug[];
}
@@ -0,0 +1,100 @@
import { spfi, SPFx } from '@pnp/sp';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
import '@pnp/sp/site-users/web';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { getRequestTypes, IRequestType } from '../../shared/approval';
import { IMyRequest } from './models/IMyRequest';
import { IMyRequestsResult, IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
// Заявки текущего пользователя по всем типам заявок, известным approval-route API.
// Для каждого типа с заполненными listId/siteUrl читаем список данных на его сайте
// и берём строки, где Author — текущий пользователь. Типы без listId/siteUrl
// (ещё не настроены на бэкенде) пропускаются. Ошибка по одному списку (нет прав,
// список удалён и т.п.) не должна ронять всю панель — такие типы просто дают
// пустой результат. debug возвращается всегда — панель показывает его как
// отладочную информацию (что пропущено и почему, что упало с ошибкой).
export async function getMyRequests(context: WebPartContext): Promise<IMyRequestsResult> {
const types = await getRequestTypes(context);
const perType = await Promise.all(
types.map((t) => (t.listId && t.siteUrl ? getMyRequestsForType(context, t) : skipType(t)))
);
return {
rows: perType.reduce((all, r) => all.concat(r.rows), [] as IMyRequest[]),
debug: perType.map((r) => r.debug),
};
}
function skipType(type: IRequestType): { rows: IMyRequest[]; debug: IMyRequestsTypeDebug } {
return {
rows: [],
debug: {
requestTypeId: type.id,
requestTypeName: type.name,
listId: type.listId,
siteUrl: type.siteUrl,
skipped: true,
},
};
}
async function getMyRequestsForType(
context: WebPartContext,
type: IRequestType
): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug }> {
const debug: IMyRequestsTypeDebug = {
requestTypeId: type.id,
requestTypeName: type.name,
listId: type.listId,
siteUrl: type.siteUrl,
skipped: false,
};
try {
const sp = spfi(type.siteUrl as string).using(SPFx(context));
const list = sp.web.getList(type.listId as string);
const me = await sp.web.currentUser.select('Id')();
const rows = (await list.items
.filter(`AuthorId eq ${me.Id}`)
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
.expand('Author')
.orderBy('Id', false)
.top(200)()) as Array<Record<string, unknown>>;
debug.rowCount = rows.length;
// Токен пользователя (context.pageContext.user.email) — источник правды для
// сравнения с Author/EMail списка. Не совпало (напр. Author/EMail пуст на
// профиле сайта) — строку не показываем, а не показываем что попало.
const tokenEmail = (context.pageContext.user.email || '').toLowerCase();
const mapped = rows
.map((r) => {
const author = r.Author as { EMail?: string; Title?: string } | undefined;
return {
id: r.Id as number,
title: (r.Title as string) || '',
status: (r.Status as string) || '',
authorEmail: author?.EMail || '',
authorName: author?.Title || '',
created: (r.Created as string) || '',
requestTypeId: type.id,
requestTypeName: type.name,
section: type.section,
formKey: type.pageUrl,
};
})
.filter((r) => !!tokenEmail && r.authorEmail.toLowerCase() === tokenEmail);
return { rows: mapped, debug };
} catch (e) {
debug.error = (e as Error).message;
console.error(`Мои заявки: не удалось прочитать список типа «${type.name}»:`, debug.error);
return { rows: [], debug };
}
}
@@ -10,7 +10,6 @@
// ── Заглушка формы (до появления реальных форм) ── // ── Заглушка формы (до появления реальных форм) ──
.formPlaceholder { .formPlaceholder {
margin-top: $sp-6;
padding: $sp-8; padding: $sp-8;
background: $white; background: $white;
border: 1px solid $gray-200; border: 1px solid $gray-200;
@@ -44,7 +43,6 @@
} }
.loading { .loading {
margin-top: $sp-6;
padding: $sp-8; padding: $sp-8;
color: $gray-500; color: $gray-500;
font-size: $fs-sm; font-size: $fs-sm;
@@ -1,41 +1,39 @@
import * as React from 'react'; import * as React from 'react';
import { useState, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './SubmitFeature.module.scss'; import styles from './SubmitFeature.module.scss';
import { Icon } from '../../shared/ui/Icon/Icon'; import { Icon } from '../../shared/ui/Icon/Icon';
import { CategoryTabs } from '../../components/CategoryTabs/CategoryTabs';
import { RequestGrid } from '../../components/RequestGrid/RequestGrid'; import { RequestGrid } from '../../components/RequestGrid/RequestGrid';
import { CATEGORIES, DEFAULT_CATEGORY_ID } from '../../data/categories';
import { getRequestsByCategory, REQUEST_TYPES } from '../../data/requests'; import { getRequestsByCategory, REQUEST_TYPES } from '../../data/requests';
import { getFormComponent } from '../../forms/registry'; import { getFormComponent } from '../../forms/registry';
import { IRequestType } from '../../models/types'; import { IRequestType } from '../../models/types';
export interface ISubmitFeatureProps { export interface ISubmitFeatureProps {
context: WebPartContext; context: WebPartContext;
/** Активная категория. Вкладки теперь рендерятся на уровне портала (RequestPortal),
* чтобы оставаться на экране и в режиме согласования — эта фича их только слушает. */
activeCategoryId: string;
/** Открыть сразу форму с предзаполнением (производная заявка), минуя плитки. */ /** Открыть сразу форму с предзаполнением (производная заявка), минуя плитки. */
initialForm?: { formKey: string; sourceId: number }; initialForm?: { formKey: string; sourceId: number };
} }
// Фича «Подать заявку»: категории → плитки → беспоук-форма. // Фича «Подать заявку»: плитки → беспоук-форма (категории/вкладки — в RequestPortal).
// Владелец: автор портала. Живёт целиком в features/submit/**. // Владелец: автор портала. Живёт целиком в features/submit/**.
const SubmitFeature: React.FC<ISubmitFeatureProps> = ({ context, initialForm }) => { const SubmitFeature: React.FC<ISubmitFeatureProps> = ({ context, activeCategoryId, initialForm }) => {
// Если пришли по ссылке «создать на основе» — сразу открываем нужную форму. // Если пришли по ссылке «создать на основе» — сразу открываем нужную форму.
const initialSelected = useMemo( const initialSelected = useMemo(
() => (initialForm ? REQUEST_TYPES.filter((r) => r.id === initialForm.formKey)[0] ?? null : null), () => (initialForm ? REQUEST_TYPES.filter((r) => r.id === initialForm.formKey)[0] ?? null : null),
[initialForm] [initialForm]
); );
const [activeCategoryId, setActiveCategoryId] = useState<string>(
initialSelected ? initialSelected.categoryId : DEFAULT_CATEGORY_ID
);
const [selectedRequest, setSelectedRequest] = useState<IRequestType | null>(initialSelected); const [selectedRequest, setSelectedRequest] = useState<IRequestType | null>(initialSelected);
const requests = getRequestsByCategory(activeCategoryId); // Смена категории (вкладка сверху) — возвращаемся к списку плиток.
useEffect(() => {
setSelectedRequest((prev) => (prev && prev.categoryId !== activeCategoryId ? null : prev));
}, [activeCategoryId]);
const handleCategoryChange = (categoryId: string): void => { const requests = getRequestsByCategory(activeCategoryId);
setActiveCategoryId(categoryId);
setSelectedRequest(null); // при смене категории возвращаемся к списку плиток
};
const handleBack = (): void => setSelectedRequest(null); const handleBack = (): void => setSelectedRequest(null);
@@ -69,12 +67,6 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({ context, initialForm })
return ( return (
<div className={styles.content}> <div className={styles.content}>
<CategoryTabs
categories={CATEGORIES}
activeId={activeCategoryId}
onChange={handleCategoryChange}
/>
{selectedRequest ? ( {selectedRequest ? (
renderSelected(selectedRequest) renderSelected(selectedRequest)
) : ( ) : (
@@ -8,6 +8,11 @@ export interface IRequestType {
id: number; id: number;
name: string; name: string;
pageUrl: string; pageUrl: string;
/** Серверный относительный URL списка данных (напр. "/sites/requests/Lists/List1"). undefined, пока не настроено. */
listId?: string;
/** Абсолютный URL сайта, где живёт этот список. undefined, пока не настроено. */
siteUrl?: string;
section: string;
} }
// Обёртка ответа API (все ответы приходят в поле data). // Обёртка ответа API (все ответы приходят в поле data).
@@ -1,3 +1,5 @@
import * as React from 'react';
// Маршрутизация портала по параметрам URL. // Маршрутизация портала по параметрам URL.
// //
// Обычный режим (нет параметров) — подача заявок (features/submit). // Обычный режим (нет параметров) — подача заявок (features/submit).
@@ -8,6 +10,11 @@
// Префикс rp (request-portal): имена id/mode/type зарезервированы SharePoint // Префикс rp (request-portal): имена id/mode/type зарезервированы SharePoint
// (напр. ?id= он трактует как навигацию к элементу → «Элемент не существует»), // (напр. ?id= он трактует как навигацию к элементу → «Элемент не существует»),
// поэтому используем собственные, неконфликтные имена параметров. // поэтому используем собственные, неконфликтные имена параметров.
//
// Навигация внутри портала (напр. клик по заявке в «Мои заявки») идёт через
// navigate() — это history.pushState без перезагрузки страницы. useRoute()
// подписан и на popstate (кнопка «назад»), и на её собственное событие,
// поэтому реагирует на оба способа смены URL.
export type PortalMode = 'submit' | 'approve' | 'create'; export type PortalMode = 'submit' | 'approve' | 'create';
@@ -21,6 +28,8 @@ export interface IRoute {
sourceId?: number; sourceId?: number;
} }
const ROUTE_CHANGE_EVENT = 'rp-route-change';
/** Разобрать строку запроса (по умолчанию — из адреса страницы) в маршрут. */ /** Разобрать строку запроса (по умолчанию — из адреса страницы) в маршрут. */
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);
@@ -41,3 +50,68 @@ export function parseRoute(search: string = window.location.search): IRoute {
} }
return { mode: 'submit' }; return { mode: 'submit' };
} }
/**
* Перейти на другой маршрут без перезагрузки страницы (SPA): обновляет
* query-параметры адресной строки через history.pushState и оповещает
* useRoute() своим событием (popstate на pushState сам не срабатывает).
*/
export function navigate(route: IRoute): void {
const params = new URLSearchParams(window.location.search);
params.delete('rpmode');
params.delete('rptype');
params.delete('rpid');
params.delete('rpsource');
if (route.mode === 'approve' && route.formKey && route.itemId) {
params.set('rpmode', 'approve');
params.set('rptype', route.formKey);
params.set('rpid', String(route.itemId));
} else if (route.mode === 'create' && route.formKey && route.sourceId) {
params.set('rptype', route.formKey);
params.set('rpsource', String(route.sourceId));
}
const query = params.toString();
const url = `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`;
window.history.pushState({}, '', url);
window.dispatchEvent(new Event(ROUTE_CHANGE_EVENT));
}
/** Ссылка на маршрут (для href — открытие в новой вкладке, копирование и т.п.). */
export function routeUrl(route: IRoute): string {
const params = new URLSearchParams(window.location.search);
params.delete('rpmode');
params.delete('rptype');
params.delete('rpid');
params.delete('rpsource');
if (route.mode === 'approve' && route.formKey && route.itemId) {
params.set('rpmode', 'approve');
params.set('rptype', route.formKey);
params.set('rpid', String(route.itemId));
} else if (route.mode === 'create' && route.formKey && route.sourceId) {
params.set('rptype', route.formKey);
params.set('rpsource', String(route.sourceId));
}
const query = params.toString();
return `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`;
}
/** Текущий маршрут, реактивно обновляется по navigate() и кнопке «назад». */
export function useRoute(): IRoute {
const [route, setRoute] = React.useState<IRoute>(() => parseRoute());
React.useEffect(() => {
const onChange = (): void => setRoute(parseRoute());
window.addEventListener('popstate', onChange);
window.addEventListener(ROUTE_CHANGE_EVENT, onChange);
return () => {
window.removeEventListener('popstate', onChange);
window.removeEventListener(ROUTE_CHANGE_EVENT, onChange);
};
}, []);
return route;
}