9 Commits

Author SHA1 Message Date
gorbenko.pavel e989c26c30 вроде готово 2026-08-04 15:53:09 +05:00
gorbenko.pavel 3684c75a0c куча правок 2026-08-04 15:38:05 +05:00
gorbenko.pavel 3a68f2a1c3 различные правки 2026-08-04 14:36:47 +05:00
gorbenko.pavel 687ba5a753 теперь заявки на согласование тоже пишуться 2026-08-04 14:25:19 +05:00
gorbenko.pavel 8a67737299 Merge remote-tracking branch 'origin/main' into left-panel 2026-08-04 12:37:17 +05:00
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
15 changed files with 1172 additions and 52 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;
} }
@@ -52,6 +52,35 @@
position: sticky; position: sticky;
top: $sp-4; top: $sp-4;
align-self: stretch; align-self: stretch;
transition: flex-basis .2s;
}
.sideCollapsed {
flex-basis: 44px;
}
.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;
}
} }
// На узких экранах панель уходит под формы. // На узких экранах панель уходит под формы.
@@ -2,35 +2,92 @@ 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 [panelCollapsed, setPanelCollapsed] = React.useState<boolean>(false);
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;
});
// Клик по заявке в «Текущие заявки» (или ссылка «создать на основе») — переключает
// вкладку категории на ту, к которой относится тип этой заявки, чтобы вкладки
// сверху совпадали с тем, что сейчас открыто в main.
React.useEffect(() => {
if ((route.mode === 'approve' || route.mode === 'create') && route.formKey) {
const found = REQUEST_TYPES.filter((r) => r.id === route.formKey)[0];
if (found) setActiveCategoryId(found.categoryId);
}
}, [route.mode, route.formKey]);
return ( return (
<section className={rootClasses}> <section className={rootClasses}>
<IconSprite /> <IconSprite />
<div className={styles.layout}>
<div className={`${styles.side} ${panelCollapsed ? styles.sideCollapsed : ''}`}>
<MyRequestsPanel
context={context}
collapsed={panelCollapsed}
onToggleCollapsed={() => setPanelCollapsed((v) => !v)}
/>
</div>
<main className={styles.main}>
<div className={styles.tabsWrap}>
<CategoryTabs
categories={CATEGORIES}
activeId={activeCategoryId}
onChange={(id) => {
setActiveCategoryId(id);
// Клик по вкладке всегда возвращает к списку плиток: в режиме
// подачи это уже делает сам SubmitFeature (сброс выбранной формы
// при смене категории), а в режиме согласования (заявка открыта
// из «Текущие заявки») route сам не меняется — выходим явно,
// чтобы поведение вкладок было одинаковым в обоих случаях.
if (route.mode === 'approve') {
navigate({ mode: 'submit' });
}
}}
/>
</div>
{route.mode === 'approve' && route.formKey && route.itemId ? ( {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} /> <ApproveFeature context={context} formKey={route.formKey} itemId={route.itemId} />
</>
) : ( ) : (
<SubmitFeature <SubmitFeature
context={context} context={context}
activeCategoryId={activeCategoryId}
initialForm={ initialForm={
route.mode === 'create' && route.formKey && route.sourceId route.mode === 'create' && route.formKey && route.sourceId
? { formKey: route.formKey, sourceId: route.sourceId } ? { formKey: route.formKey, sourceId: route.sourceId }
@@ -38,6 +95,8 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
} }
/> />
)} )}
</main>
</div>
</section> </section>
); );
}; };
@@ -14,20 +14,260 @@
} }
.header { .header {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
padding: $sp-4 $sp-5; padding: $sp-4 $sp-5;
border-bottom: 1px solid $gray-200; border-bottom: 1px solid $gray-200;
} }
.collapseToggle {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
color: $navy-700;
cursor: pointer;
transition: .16s;
&:hover {
border-color: $gold-400;
color: $gold-600;
}
}
.panelCollapsed {
align-items: center;
padding: $sp-3 0;
}
.title { .title {
font-family: $font-serif; font-family: $font-serif;
font-size: 18px; font-size: 18px;
color: $navy-900; color: $navy-900;
} }
.searchWrap {
padding: $sp-3 $sp-5 0;
}
.search {
width: 100%;
padding: 8px 12px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
transition: .18s;
@include input-focus-gold;
&::placeholder {
color: $gray-400;
}
}
.body { .body {
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;
}
.section + .section {
margin-top: $sp-6;
padding-top: $sp-6;
border-top: 1px solid $gray-200;
}
.sectionTitle {
font-family: $font-sans;
font-size: $fs-caption;
font-weight: 600;
color: $gray-500;
text-transform: uppercase;
letter-spacing: .04em;
margin-bottom: $sp-4;
}
.pager {
display: flex;
align-items: center;
gap: 4px;
margin-top: $sp-3;
flex-wrap: wrap;
}
.pagerBtn {
min-width: 24px;
height: 24px;
padding: 0 6px;
border: 1px solid $gray-200;
border-radius: $r-sm;
background: $white;
color: $gray-700;
font-family: $font-sans;
font-size: $fs-caption;
cursor: pointer;
transition: .16s;
&:hover:not(:disabled) {
border-color: $gold-400;
color: $gold-600;
}
&:disabled {
opacity: .4;
cursor: default;
}
}
.pagerBtnActive {
background: $navy-800;
border-color: $navy-800;
color: $white;
&:hover {
border-color: $navy-800 !important;
color: $white !important;
}
}
.pagerEllipsis {
color: $gray-400;
font-size: $fs-caption;
padding: 0 2px;
}
.table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: $fs-caption;
th {
text-align: left;
font-weight: 600;
color: $gray-500;
font-size: $fs-caption;
text-transform: uppercase;
letter-spacing: .03em;
padding: 6px 8px;
border-bottom: 1px solid $gray-200;
}
td {
// Высокие строки + перенос по словам: панель узкая, а длинные названия и
// e-mail не должны обрезаться — пусть занимают несколько строк.
height: 66px;
padding: $sp-3 8px;
border-bottom: 1px solid $gray-100;
vertical-align: middle;
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
line-height: 1.4;
}
}
.row {
transition: background .16s;
&:hover {
background: $gold-50;
}
}
.cellType {
width: 72px;
color: $gold-600;
font-weight: 600;
}
.cellTitle {
// Ширину не задаём — делит оставшееся место с cellAuthor (table-layout: fixed).
min-width: 0;
}
.titleLink {
display: block;
color: $gray-900;
text-decoration: none;
&:hover {
color: $gold-600;
text-decoration: underline;
}
}
.cellAuthor {
color: $gray-500;
}
.cellDate {
width: 64px;
color: $gray-400;
}
.cellStatus {
width: 96px;
}
.status {
// inline-block, а не flex-элемент: бейдж живёт внутри ячейки таблицы и
// переносится по словам (напр. «Возвращена на доработку» в узкой колонке).
display: inline-block;
padding: 3px 8px;
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,23 +1,373 @@
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, getPendingApproval, searchOwnRequests } from './myRequests.service';
import { IMyRequest } from './models/IMyRequest';
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
import { navigate, routeUrl } from '../../shared/routing';
import { onRequestsChanged } from '../../shared/requestEvents';
import { Icon } from '../../shared/ui/Icon/Icon';
import styles from './MyRequestsPanel.module.scss'; import styles from './MyRequestsPanel.module.scss';
export interface IMyRequestsPanelProps { export interface IMyRequestsPanelProps {
context: WebPartContext; context: WebPartContext;
/** Свёрнута ли панель (управляется снаружи — RequestPortal меняет ширину колонки). */
collapsed?: boolean;
onToggleCollapsed?: () => void;
} }
// Постоянная боковая панель «Мои заявки»: пользователь видит, что он отправлял const PAGE_SIZE = 20;
// и согласовывал. Всегда открыта (без кнопки/оверлея).
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;
}
}
// «Ждёt вашего решения»: автор здесь — не вы, поэтому важна колонка «Автор»,
// а статус не нужен (в этой секции все заявки по определению «на согласовании»).
// «Созданы вами»: автор — всегда вы, колонку не показываем, зато нужен статус.
const RequestRow: React.FC<{ r: IMyRequest; variant: 'own' | 'approval' }> = ({ r, variant }) => (
<tr className={styles.row}>
<td className={styles.cellType}>{r.requestTypeName}</td>
<td className={styles.cellTitle}>
<a
className={styles.titleLink}
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 });
}}
>
{r.title || `#${r.id}`}
</a>
</td>
{variant === 'approval' && (
<td className={styles.cellAuthor}>
{[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'}
</td>
)}
<td className={styles.cellDate}>{formatDate(r.created)}</td>
{variant === 'own' && (
<td className={styles.cellStatus}>
<span className={`${styles.status} ${statusClass(r.status)}`}>{r.status || '—'}</span>
</td>
)}
</tr>
);
const RequestsTable: React.FC<{ rows: IMyRequest[]; variant: 'own' | 'approval' }> = ({ rows, variant }) => (
<table className={styles.table}>
<thead>
<tr>
<th className={styles.cellType}>Тип</th>
<th className={styles.cellTitle}>Название</th>
{variant === 'approval' && <th className={styles.cellAuthor}>Автор</th>}
<th className={styles.cellDate}>Дата</th>
{variant === 'own' && <th className={styles.cellStatus}>Статус</th>}
</tr>
</thead>
<tbody>
{rows.map((r) => (
<RequestRow r={r} variant={variant} key={r.id} />
))}
</tbody>
</table>
);
// Постраничная навигация: номера страниц + вперёд/назад. При большом
// количестве страниц показывает только соседние с текущей + первую/последнюю.
const Pager: React.FC<{ page: number; totalPages: number; onChange: (page: number) => void }> = ({
page,
totalPages,
onChange,
}) => {
if (totalPages <= 1) return null;
const numbers: number[] = [];
const from = Math.max(1, page - 2);
const to = Math.min(totalPages, page + 2);
for (let i = from; i <= to; i++) numbers.push(i);
return (
<div className={styles.pager}>
<button
type="button"
className={styles.pagerBtn}
disabled={page <= 1}
onClick={() => onChange(page - 1)}
aria-label="Предыдущая страница"
>
‹
</button>
{from > 1 && (
<>
<button type="button" className={styles.pagerBtn} onClick={() => onChange(1)}>
1
</button>
{from > 2 && <span className={styles.pagerEllipsis}>…</span>}
</>
)}
{numbers.map((n) => (
<button
key={n}
type="button"
className={`${styles.pagerBtn} ${n === page ? styles.pagerBtnActive : ''}`}
onClick={() => onChange(n)}
>
{n}
</button>
))}
{to < totalPages && (
<>
{to < totalPages - 1 && <span className={styles.pagerEllipsis}>…</span>}
<button type="button" className={styles.pagerBtn} onClick={() => onChange(totalPages)}>
{totalPages}
</button>
</>
)}
<button
type="button"
className={styles.pagerBtn}
disabled={page >= totalPages}
onClick={() => onChange(page + 1)}
aria-label="Следующая страница"
>
›
</button>
</div>
);
};
// «Ждёт вашего решения»: данные уже загружены целиком (алгоритм требует видеть
// весь маршрут заявки, чтобы понять, чья сейчас очередь) — один общий список
// по всем типам, пагинация — клиентская нарезка по этому списку.
const PendingSection: React.FC<{ rows: IMyRequest[] }> = ({ rows }) => {
const [page, setPage] = React.useState(1);
React.useEffect(() => setPage(1), [rows]);
if (rows.length === 0) {
return <div className={styles.empty}>Нет заявок, ждущих вашего решения.</div>;
}
const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
const pageRows = rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
return (
<>
<RequestsTable rows={pageRows} variant="approval" />
<Pager page={page} totalPages={totalPages} onChange={setPage} />
</>
);
};
// «Созданы вами»: один общий постраничный поиск по всем типам сразу через
// SharePoint Search REST API (см. myRequests.service.searchOwnRequests) —
// единая пагинация, без разбивки списка по типам заявок.
const OwnSection: React.FC<{ context: WebPartContext; types: IRequestType[]; query: string }> = ({
context,
types,
query,
}) => {
const [page, setPage] = React.useState(1);
const [rows, setRows] = React.useState<IMyRequest[]>([]);
const [totalRows, setTotalRows] = React.useState<number | undefined>(undefined);
const [error, setError] = React.useState<string>('');
React.useEffect(() => setPage(1), [query]);
React.useEffect(() => {
if (types.length === 0) {
setTotalRows(0);
return;
}
let cancelled = false;
searchOwnRequests(context, types, query, page)
.then((result) => {
if (cancelled) return;
setRows(result.rows);
setTotalRows(result.totalRows);
setError('');
})
.catch((e) => {
if (!cancelled) setError((e as Error).message);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [context, types, query, page]);
if (error) {
return <div className={styles.error}>Не удалось загрузить: {error}</div>;
}
if (totalRows === undefined) {
return <div className={styles.empty}>Загрузка…</div>;
}
if (totalRows === 0) {
return <div className={styles.empty}>Вы ещё не создавали заявок.</div>;
}
const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE));
return (
<>
<RequestsTable rows={rows} variant="own" />
<Pager page={page} totalPages={totalPages} onChange={setPage} />
</>
);
};
// Постоянная боковая панель «Текущие заявки»: заявки, созданные текущим
// пользователем, и заявки, ждущие сейчас его решения как согласующего —
// по всем типам заявок, известным approval-route API, в двух общих списках
// без разбивки по типам (тип виден на самой карточке).
// Владелец: второй разработчик. Вся логика/данные — внутри этой папки. // Владелец: второй разработчик. Вся логика/данные — внутри этой папки.
// Общий UI-кит (shared/ui), pnpjs и config можно импортировать, но НЕ менять. // Общий UI-кит (shared/ui), pnpjs и config можно импортировать, но НЕ менять.
const MyRequestsPanel: React.FC<IMyRequestsPanelProps> = (/* { context } */) => { //
// «Ждёт вашего решения» перечитывается по событию rp-requests-changed
// (shared/requestEvents) — оно шлётся после сохранения формы и после решения
// согласующего. «Созданы вами» идёт через SharePoint Search (см.
// myRequests.service) постранично — намеренно НЕ обновляется по этому
// событию: только что созданная заявка появится там, когда её проиндексирует
// поиск (задержка приемлема).
//
// Поиск по названию: «Ждёт вашего решения» фильтруется на клиенте (данные уже
// загружены целиком), «Созданы вами» — реальный запрос к Search REST API
// (может слегка отставать от только что созданных/переименованных заявок).
//
// Диагностика (listId/siteUrl/rowCount/error, KQL-запросы) в UI не выводится —
// см. console.log/console.table в devtools при загрузке панели.
const MyRequestsPanel: React.FC<IMyRequestsPanelProps> = ({ context, collapsed, onToggleCollapsed }) => {
const [linkedTypes, setLinkedTypes] = React.useState<IRequestType[] | undefined>(undefined);
const [pendingRows, setPendingRows] = React.useState<IMyRequest[] | undefined>(undefined);
const [error, setError] = React.useState<string>('');
const [query, setQuery] = React.useState<string>('');
React.useEffect(() => {
let cancelled = false;
getLinkedTypes(context)
.then((types) => {
if (!cancelled) setLinkedTypes(types);
})
.catch((e) => {
if (!cancelled) setError((e as Error).message);
});
return () => {
cancelled = true;
};
}, [context]);
React.useEffect(() => {
let cancelled = false;
const load = (): void => {
getPendingApproval(context)
.then((result) => {
if (cancelled) return;
setPendingRows(result.rows);
console.table(result.debug);
})
.catch((e) => {
if (!cancelled) setError((e as Error).message);
});
};
load();
const unsubscribe = onRequestsChanged(load);
return () => {
cancelled = true;
unsubscribe();
};
}, [context]);
const q = query.trim().toLowerCase();
const filteredPending =
pendingRows && q ? pendingRows.filter((r) => r.title.toLowerCase().indexOf(q) !== -1) : pendingRows;
const nothingLoaded = linkedTypes !== undefined && pendingRows !== undefined;
if (collapsed) {
return ( return (
<aside className={styles.panel} aria-label="Мои заявки"> <aside className={`${styles.panel} ${styles.panelCollapsed}`} aria-label="Текущие заявки (свёрнуто)">
<button
type="button"
className={styles.collapseToggle}
onClick={onToggleCollapsed}
aria-label="Показать «Текущие заявки»"
title="Показать «Текущие заявки»"
>
<Icon name="chevron-right" size="sm" />
</button>
</aside>
);
}
return (
<aside className={styles.panel} aria-label="Текущие заявки">
<div className={styles.header}> <div className={styles.header}>
<span className={styles.title}>Мои заявки</span> <span className={styles.title}>Текущие заявки</span>
<button
type="button"
className={styles.collapseToggle}
onClick={onToggleCollapsed}
aria-label="Свернуть «Текущие заявки»"
title="Свернуть «Текущие заявки»"
>
<Icon name="chevron-left" size="sm" />
</button>
</div>
<div className={styles.searchWrap}>
<input
type="search"
className={styles.search}
placeholder="Поиск по названию…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div> </div>
<div className={styles.body}> <div className={styles.body}>
Раздел в разработке: здесь будут заявки пользователя, статусы и согласования. {error && <div className={styles.error}>Не удалось загрузить заявки: {error}</div>}
{!error && !nothingLoaded && <div className={styles.empty}>Загрузка…</div>}
{!error && nothingLoaded && (
<div className={styles.section}>
<div className={styles.sectionTitle}>Ждёт вашего решения</div>
<PendingSection rows={filteredPending || []} />
</div>
)}
{!error && nothingLoaded && (
<div className={styles.section}>
<div className={styles.sectionTitle}>Созданы вами</div>
<OwnSection context={context} types={linkedTypes || []} query={q} />
</div>
)}
</div> </div>
</aside> </aside>
); );
@@ -0,0 +1,18 @@
// Строка заявки пользователя для боковой панели «Мои заявки».
// Собирается из списка данных 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;
/** 'own' — заявка, созданная текущим пользователем; 'approval' — ждёт его решения. */
kind: 'own' | 'approval';
}
@@ -0,0 +1,24 @@
// Диагностика по одному типу заявки — для отладочного блока в панели «Мои заявки».
export interface IMyRequestsTypeDebug {
requestTypeId: number;
requestTypeName: string;
listId?: string;
siteUrl?: string;
/** Тип пропущен: у него нет listId/siteUrl в approval-route API. */
skipped: boolean;
/** Сколько строк текущего пользователя найдено (undefined, если не дошли до запроса). */
rowCount?: number;
/** Текст ошибки, если запрос к списку упал. */
error?: string;
/** Заголовок списка согласования, вычисленный из имени списка данных. */
approvalListTitle?: string;
/** Список данных не похож на «... — данные» — список согласования не искали. */
approvalSkippedReason?: string;
/** Сколько строк маршрута (фаза «Согласование») прочитано из списка согласования. */
approvalRowCount?: number;
/** Сколько заявок сейчас ждут решения текущего пользователя. */
approvalPendingTickets?: number;
/** Текст ошибки, если запрос к списку согласования упал. */
approvalError?: string;
}
@@ -0,0 +1,306 @@
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 '@pnp/sp/search';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { getRequestTypes, IRequestType, REQUEST_STATUS } from '../../shared/approval';
import { IMyRequest } from './models/IMyRequest';
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
// Список данных «... — данные» → список согласования «... — согласование»
// (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json).
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
interface IApprovalStep {
status: string;
order?: number;
approverEmail: string;
}
export function currentUserEmail(context: WebPartContext): string {
return (context.pageContext.user.email || '').toLowerCase();
}
// Типы заявок, у которых approval-route API уже отдал listId/siteUrl (готовы к чтению).
export async function getLinkedTypes(context: WebPartContext): Promise<IRequestType[]> {
const types = await getRequestTypes(context);
return types.filter((t) => !!t.listId && !!t.siteUrl);
}
// ---------------------------------------------------------------------------
// «Ждёт вашего решения» — требует видеть ВЕСЬ маршрут заявки, чтобы понять,
// чья сейчас очередь, поэтому идёт обычным REST-запросом (не через Search),
// целиком за один раз. Пагинация по этому результату — на стороне панели
// (клиентская нарезка по страницам), т.к. сам список обычно небольшой
// (сколько заявок сейчас реально ждут решения ОДНОГО согласующего).
export async function getPendingApproval(
context: WebPartContext
): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug[] }> {
const types = await getLinkedTypes(context);
const tokenEmail = currentUserEmail(context);
const perType = await Promise.all(
types.map((t) => getApprovalPendingForType(context, t, tokenEmail))
);
return {
rows: perType.reduce((all, r) => all.concat(r.rows), [] as IMyRequest[]),
debug: perType.map((r) => r.debug),
};
}
async function getApprovalPendingForType(
context: WebPartContext,
type: IRequestType,
tokenEmail: string
): 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 dataListInfo = await list.select('Title')();
const dataTitle = (dataListInfo.Title as string) || '';
if (!DATA_LIST_SUFFIX.test(dataTitle)) {
debug.approvalSkippedReason = `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`;
return { rows: [], debug };
}
const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
debug.approvalListTitle = approvalListTitle;
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
// Phase не смотрим вообще — работаем только по Status.
const stepRows = (await approvalList.items
.select('Id', 'StepOrder', 'Status', 'TicketId', 'Approver/EMail')
.expand('Approver')
.top(2000)()) as Array<Record<string, unknown>>;
console.log(
`[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», элементов в списке: ${stepRows.length}, ваш email из токена: «${tokenEmail}»`
);
console.table(
stepRows.map((r) => ({
Id: r.Id,
TicketId: r.TicketId,
StepOrder: r.StepOrder,
Status: r.Status,
'Approver/EMail': (r.Approver as { EMail?: string } | undefined)?.EMail,
}))
);
debug.approvalRowCount = stepRows.length;
const byTicket = new Map<number, IApprovalStep[]>();
stepRows.forEach((r) => {
const ticketId = r.TicketId as number | undefined;
if (!ticketId) return;
const approver = r.Approver as { EMail?: string } | undefined;
const steps = byTicket.get(ticketId) || [];
steps.push({
status: (r.Status as string) || '',
order: typeof r.StepOrder === 'number' ? (r.StepOrder as number) : undefined,
approverEmail: (approver?.EMail || '').toLowerCase(),
});
byTicket.set(ticketId, steps);
});
const myTicketIds: number[] = [];
const byTicketDebug: Array<Record<string, unknown>> = [];
byTicket.forEach((steps, ticketId) => {
const mine = isMyTurn(steps, tokenEmail);
byTicketDebug.push({ TicketId: ticketId, isMyTurn: mine, steps: JSON.stringify(steps) });
if (mine) myTicketIds.push(ticketId);
});
console.table(byTicketDebug);
debug.approvalPendingTickets = myTicketIds.length;
if (myTicketIds.length === 0) return { rows: [], debug };
const ticketFilter = myTicketIds.map((id) => `Id eq ${id}`).join(' or ');
const ticketRows = (await list.items
.filter(ticketFilter)
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
.expand('Author')
.top(2000)()) as Array<Record<string, unknown>>;
const rows = ticketRows.map((r) => {
const author = r.Author as { EMail?: string; Title?: string } | undefined;
return toMyRequest(r, author, type, 'approval');
});
return { rows, debug };
} catch (e) {
debug.approvalError = (e as Error).message;
console.error(
`Мои заявки: не удалось прочитать согласование для «${type.name}»:`,
debug.approvalError
);
return { rows: [], debug };
}
}
// Сейчас ли очередь пользователя с данным email среди строк маршрута одной заявки.
// Маршрут остановлен (где-то есть Отклонено/Возвращена) — не его очередь, заявка
// не активна. Иначе «текущие» строки — это ещё не решённые (Status = На согласовании)
// без указанного StepOrder (параллельно, все активны сразу) плюс, если есть строки
// с указанным StepOrder, ещё не решённая с минимальным StepOrder (последовательно).
function isMyTurn(steps: IApprovalStep[], tokenEmail: string): boolean {
if (!tokenEmail) return false;
const stopped = steps.some(
(s) => s.status === REQUEST_STATUS.rejected || s.status === REQUEST_STATUS.returned
);
if (stopped) return false;
const pending = steps.filter((s) => s.status === REQUEST_STATUS.onApproval);
const noOrder = pending.filter((s) => s.order === undefined);
const ordered = pending.filter((s): s is IApprovalStep & { order: number } => s.order !== undefined);
const current = [...noOrder];
if (ordered.length > 0) {
const minOrder = Math.min(...ordered.map((s) => s.order));
current.push(...ordered.filter((s) => s.order === minOrder));
}
return current.some((s) => s.approverEmail === tokenEmail);
}
// ---------------------------------------------------------------------------
// «Созданы вами» — по каждому типу отдельно, через SharePoint Search REST API
// (search/query, KQL), с постраничной выборкой (RowLimit/StartRow). Заявок
// автора может накопиться много со временем, поэтому весь список целиком не
// грузим — только текущую страницу. Расплата за это — результаты идут из
// поискового индекса, который переиндексируется с задержкой (только что
// созданная/переименованная заявка может не найтись сразу).
//
// Search отдаёт только Title/Path/Id — Status и прочие поля не запрашиваем
// через Search (для кастомных колонок нет гарантии, что они вообще
// проиндексированы как managed property), а дочитываем обычным REST-запросом
// по найденным Id из самого списка данных — так надёжнее.
export interface IOwnSearchPage {
rows: IMyRequest[];
totalRows: number;
}
const PAGE_SIZE = 20;
function siteOrigin(siteUrl: string): string {
const match = /^https?:\/\/[^/]+/.exec(siteUrl);
return match ? match[0] : siteUrl;
}
// KQL не терпит спецсимволов в значении — оставляем буквы (лат./кир.), цифры и пробелы.
// (без \p{L} — целевой ES-таргет проекта его не поддерживает)
function sanitizeForKql(text: string): string {
return text
.replace(/[^a-zA-Zа-яА-ЯёЁ0-9\s]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
// Один запрос на ВСЕ типы сразу (не по одному на тип) — так у результата единая
// пагинация (одна StartRow/RowLimit/TotalRows), без разбивки по типам заявок.
// Path одного типа ищем через OR внутри запроса; после поиска по Path каждого
// найденного элемента определяем, какому типу он принадлежит, и дочитываем
// поля из ЕГО списка данных (см. комментарий выше — Search отдаёт не все поля).
export async function searchOwnRequests(
context: WebPartContext,
types: IRequestType[],
query: string,
page: number
): Promise<IOwnSearchPage> {
if (types.length === 0) return { rows: [], totalRows: 0 };
const sp = spfi(types[0].siteUrl as string).using(SPFx(context));
const tokenEmail = currentUserEmail(context);
const claimsLogin = `i:0#.f|membership|${tokenEmail}`;
const typeInfo = types.map((t) => ({ type: t, listAbsUrl: `${siteOrigin(t.siteUrl as string)}${t.listId}` }));
const pathClause = typeInfo.map((t) => `Path:"${t.listAbsUrl}*"`).join(' OR ');
const clauses = [`(${pathClause})`, `AuthorOWSUSER:"${claimsLogin}"`];
const q = sanitizeForKql(query);
if (q) clauses.push(`Title:${q}*`);
const kql = clauses.join(' AND ');
const results = await sp.search({
Querytext: kql,
RowLimit: PAGE_SIZE,
StartRow: (page - 1) * PAGE_SIZE,
SelectProperties: ['ListItemID', 'Title', 'Path'],
TrimDuplicates: false,
});
console.log(`[Текущие заявки] Search «Созданы вами»: KQL = ${kql}, всего найдено: ${results.TotalRows}`);
console.table(results.PrimarySearchResults);
const hits = results.PrimarySearchResults.map((r) => {
const rec = r as Record<string, unknown>;
return { id: Number(rec.ListItemID), path: String(rec.Path || '') };
}).filter((h) => !isNaN(h.id) && h.id > 0);
if (hits.length === 0) {
return { rows: [], totalRows: results.TotalRows || 0 };
}
// Каждый найденный элемент → его тип (по префиксу Path), чтобы дочитать поля
// из правильного списка данных.
const idsByType = new Map<IRequestType, number[]>();
hits.forEach((h) => {
const matched = typeInfo.find((t) => h.path.toLowerCase().indexOf(t.listAbsUrl.toLowerCase()) === 0);
if (!matched) return;
const ids = idsByType.get(matched.type) || [];
ids.push(h.id);
idsByType.set(matched.type, ids);
});
const byId = new Map<number, IMyRequest>();
await Promise.all(
Array.from(idsByType.entries()).map(async ([type, ids]) => {
const typeSp = spfi(type.siteUrl as string).using(SPFx(context));
const list = typeSp.web.getList(type.listId as string);
const filter = ids.map((id) => `Id eq ${id}`).join(' or ');
const rows = (await list.items
.filter(filter)
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
.expand('Author')
.top(PAGE_SIZE)()) as Array<Record<string, unknown>>;
rows.forEach((r) => {
const author = r.Author as { EMail?: string; Title?: string } | undefined;
byId.set(r.Id as number, toMyRequest(r, author, type, 'own'));
});
})
);
// Сохраняем порядок релевантности из поиска, а не порядок ответов REST.
const ordered = hits.map((h) => byId.get(h.id)).filter((r): r is IMyRequest => !!r);
return { rows: ordered, totalRows: results.TotalRows || 0 };
}
function toMyRequest(
r: Record<string, unknown>,
author: { EMail?: string; Title?: string } | undefined,
type: IRequestType,
kind: 'own' | 'approval'
): IMyRequest {
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,
kind,
};
}
@@ -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,40 @@
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';
import { emitRequestsChanged } from '../../shared/requestEvents';
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);
@@ -62,19 +61,19 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({ context, initialForm })
// Ленивый рендер формы: её код подтягивается только сейчас, при открытии. // Ленивый рендер формы: её код подтягивается только сейчас, при открытии.
return ( return (
<React.Suspense fallback={<div className={styles.loading}>Загрузка формы…</div>}> <React.Suspense fallback={<div className={styles.loading}>Загрузка формы…</div>}>
<FormComponent requestType={request} context={context} onCancel={handleBack} sourceId={sourceId} /> <FormComponent
requestType={request}
context={context}
onCancel={handleBack}
onSaved={emitRequestsChanged}
sourceId={sourceId}
/>
</React.Suspense> </React.Suspense>
); );
}; };
return ( return (
<div className={styles.content}> <div className={styles.content}>
<CategoryTabs
categories={CATEGORIES}
activeId={activeCategoryId}
onChange={handleCategoryChange}
/>
{selectedRequest ? ( {selectedRequest ? (
renderSelected(selectedRequest) renderSelected(selectedRequest)
) : ( ) : (
@@ -6,6 +6,7 @@ import { ApprovalListService } from './approvalList.service';
import { REQUEST_STATUS, APPROVAL_PHASE } from './models/statuses'; import { REQUEST_STATUS, APPROVAL_PHASE } from './models/statuses';
import { IApprovalRow } from './models/IApprovalRow'; import { IApprovalRow } from './models/IApprovalRow';
import { ISiteUser } from './models/ISiteUser'; import { ISiteUser } from './models/ISiteUser';
import { emitRequestsChanged } from '../requestEvents';
export type ApproverDecision = 'approve' | 'return' | 'reject'; export type ApproverDecision = 'approve' | 'return' | 'reject';
@@ -74,6 +75,7 @@ export class ApproverService {
const rows = await this.approval.getForRequest(requestId); const rows = await this.approval.getForRequest(requestId);
const requestStatus = this.computeRequestStatus(rows); const requestStatus = this.computeRequestStatus(rows);
await this.updateRequestStatus(requestId, requestStatus); await this.updateRequestStatus(requestId, requestStatus);
emitRequestsChanged();
return requestStatus; return requestStatus;
} }
@@ -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).
@@ -0,0 +1,15 @@
// Общее событие «список заявок изменился»: создана новая заявка или принято
// решение согласующего. Панель «Текущие заявки» (features/myRequests) подписана
// и перечитывает данные. Через window.dispatchEvent — проще шины состояния,
// достаточно для нечастых событий (сохранение формы, решение согласующего).
const EVENT_NAME = 'rp-requests-changed';
export function emitRequestsChanged(): void {
window.dispatchEvent(new Event(EVENT_NAME));
}
/** Подписка на изменения. Возвращает функцию отписки (вызвать в cleanup эффекта). */
export function onRequestsChanged(handler: () => void): () => void {
window.addEventListener(EVENT_NAME, handler);
return () => window.removeEventListener(EVENT_NAME, handler);
}
@@ -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;
}