Панель со списками заявок (#1)

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/1
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tnsu.kz>
Co-committed-by: gorbenko.pavel <gorbenko.pavel@tnsu.kz>
This commit was merged in pull request #1.
This commit is contained in:
2026-08-04 12:02:11 +00:00
committed by timur.gorodetskiy
parent ba4e3feee5
commit 0aa0a60e40
21 changed files with 1442 additions and 121 deletions
@@ -6,6 +6,7 @@ import { ApprovalListService } from './approvalList.service';
import { REQUEST_STATUS, APPROVAL_PHASE } from './models/statuses';
import { IApprovalRow } from './models/IApprovalRow';
import { ISiteUser } from './models/ISiteUser';
import { emitRequestsChanged } from '../requestEvents';
export type ApproverDecision = 'approve' | 'return' | 'reject';
@@ -74,6 +75,7 @@ export class ApproverService {
const rows = await this.approval.getForRequest(requestId);
const requestStatus = this.computeRequestStatus(rows);
await this.updateRequestStatus(requestId, requestStatus);
emitRequestsChanged();
return requestStatus;
}
@@ -8,6 +8,11 @@ export interface IRequestType {
id: number;
name: string;
pageUrl: string;
/** Серверный относительный URL списка данных (напр. "/sites/requests/Lists/List1"). undefined, пока не настроено. */
listId?: string;
/** Абсолютный URL сайта, где живёт этот список. undefined, пока не настроено. */
siteUrl?: string;
section: string;
}
// Обёртка ответа 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);
}
+87 -7
View File
@@ -1,18 +1,33 @@
import * as React from 'react';
// Маршрутизация портала по параметрам URL.
//
// Обычный режим (нет параметров) — подача заявок (features/submit).
// Режим согласующего — переход по ссылке из потока Power Automate:
// Раздел бокового меню — rpnav: 'new' (подать заявку, по умолчанию),
// 'approvals' (ждут моего решения), 'own' (мои заявки).
// Открытая заявка — переход по ссылке из потока Power Automate или клик по
// строке таблицы:
// …/СтраницаПортала.aspx?rpmode=approve&rptype=<форма>&rpid=<RowID>
// где rptype = ключ формы в реестре (forms/registry), rpid = RowID заявки в списке данных.
// где rptype = ключ формы в реестре (forms/registry), rpid = RowID заявки в
// списке данных. Заявка показывается в выезжающей панели ПОВЕРХ текущего
// раздела, поэтому rpnav сохраняется вместе с rpmode.
//
// Префикс rp (request-portal): имена id/mode/type зарезервированы SharePoint
// (напр. ?id= он трактует как навигацию к элементу → «Элемент не существует»),
// поэтому используем собственные, неконфликтные имена параметров.
//
// Навигация внутри портала идёт через navigate() — это history.pushState без
// перезагрузки страницы. useRoute() подписан и на popstate (кнопка «назад»),
// и на её собственное событие, поэтому реагирует на оба способа смены URL.
export type PortalMode = 'submit' | 'approve' | 'create';
/** Раздел бокового меню. */
export type NavSection = 'new' | 'approvals' | 'own';
export interface IRoute {
mode: PortalMode;
/** Раздел бокового меню; сохраняется и когда поверх открыта заявка. */
nav: NavSection;
/** Ключ формы (тип заявки) — для 'approve' и 'create'. */
formKey?: string;
/** Id заявки в списке данных — для 'approve'. */
@@ -21,23 +36,88 @@ export interface IRoute {
sourceId?: number;
}
const ROUTE_CHANGE_EVENT = 'rp-route-change';
function parseNav(raw: string | undefined): NavSection {
if (raw === 'approvals' || raw === 'own') return raw;
return 'new';
}
/** Разобрать строку запроса (по умолчанию — из адреса страницы) в маршрут. */
export function parseRoute(search: string = window.location.search): IRoute {
const params = new URLSearchParams(search);
const mode = params.get('rpmode');
const nav = parseNav(params.get('rpnav') ?? undefined);
const formKey = params.get('rptype') ?? undefined;
const idRaw = params.get('rpid');
const itemId = idRaw ? Number(idRaw) : undefined;
const sourceRaw = params.get('rpsource');
const sourceId = sourceRaw ? Number(sourceRaw) : undefined;
// Режим согласующего — при полном наборе параметров.
// Открытая заявка (панель поверх раздела) — при полном наборе параметров.
if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) {
return { mode: 'approve', formKey, itemId };
return { mode: 'approve', nav, formKey, itemId };
}
// Создание производной заявки с предзаполнением из исходной.
if (formKey && sourceId && !isNaN(sourceId)) {
return { mode: 'create', formKey, sourceId };
return { mode: 'create', nav: 'new', formKey, sourceId };
}
return { mode: 'submit' };
return { mode: 'submit', nav };
}
// Собрать query-строку маршрута, сохранив посторонние параметры страницы.
function buildQuery(route: IRoute): string {
const params = new URLSearchParams(window.location.search);
params.delete('rpmode');
params.delete('rpnav');
params.delete('rptype');
params.delete('rpid');
params.delete('rpsource');
if (route.nav !== 'new') {
params.set('rpnav', route.nav);
}
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}`;
}
/**
* Перейти на другой маршрут без перезагрузки страницы (SPA): обновляет
* query-параметры адресной строки через history.pushState и оповещает
* useRoute() своим событием (popstate на pushState сам не срабатывает).
*/
export function navigate(route: IRoute): void {
window.history.pushState({}, '', buildQuery(route));
window.dispatchEvent(new Event(ROUTE_CHANGE_EVENT));
}
/** Ссылка на маршрут (для href — открытие в новой вкладке, копирование и т.п.). */
export function routeUrl(route: IRoute): string {
return buildQuery(route);
}
/** Текущий маршрут, реактивно обновляется по 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;
}