теперь можно пройти по заявку в мои заявки

This commit is contained in:
gorbenko.pavel
2026-08-04 12:09:47 +05:00
parent 29a91a5654
commit 79e44e01be
10 changed files with 199 additions and 53 deletions
@@ -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'; import MyRequestsPanel from '../features/myRequests/MyRequestsPanel';
// Оболочка портала. Логики фич тут нет — файл общий, меняется редко. // Оболочка портала. Логики фич тут нет — файл общий, меняется редко.
// Роутинг по URL: mode=approve&type=&id= → фича согласования, иначе — подача. // Роутинг по URL: mode=approve&type=&id= → фича согласования, иначе — подача.
//
// Вкладки категорий рендерятся здесь (а не внутри SubmitFeature), чтобы
// оставаться на экране и в режиме согласования (approve) — раньше режим
// согласования подменял всю область main и вкладки пропадали вместе с ней.
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} />
<div className={styles.layout}>
<div className={styles.side}>
<MyRequestsPanel context={context} />
</div>
<main className={styles.main}>
<SubmitFeature
context={context}
initialForm={
route.mode === 'create' && route.formKey && route.sourceId
? { formKey: route.formKey, sourceId: route.sourceId }
: undefined
}
/>
</main>
</div> </div>
)} <main className={styles.main}>
<div className={styles.tabsWrap}>
<CategoryTabs
categories={CATEGORIES}
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>
); );
}; };
@@ -70,6 +70,13 @@
border: 1px solid $gray-200; border: 1px solid $gray-200;
border-radius: $r-sm; border-radius: $r-sm;
background: $gray-50; background: $gray-50;
text-decoration: none;
transition: .16s;
&:hover {
border-color: $gold-400;
background: $gold-50;
}
} }
.itemMain { .itemMain {
@@ -3,6 +3,7 @@ import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { getMyRequests } from './myRequests.service'; import { getMyRequests } from './myRequests.service';
import { IMyRequest } from './models/IMyRequest'; import { IMyRequest } from './models/IMyRequest';
import { REQUEST_STATUS } from '../../shared/approval'; 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 {
@@ -97,17 +98,32 @@ const MyRequestsPanel: React.FC<IMyRequestsPanelProps> = ({ context }) => {
<div className={styles.groupTitle}>{group.requestTypeName}</div> <div className={styles.groupTitle}>{group.requestTypeName}</div>
<ul className={styles.list}> <ul className={styles.list}>
{group.rows.map((r) => ( {group.rows.map((r) => (
<li className={styles.item} key={`${r.requestTypeId}-${r.id}`}> <li key={`${r.requestTypeId}-${r.id}`}>
<div className={styles.itemMain}> <a
<span className={styles.itemTitle}>{r.title || `#${r.id}`}</span> className={styles.item}
<span className={styles.itemAuthor}> href={routeUrl({ mode: 'approve', formKey: r.formKey, itemId: r.id })}
{r.authorEmail || r.authorName || '— автор не определён —'} 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> </span>
{r.created && <span className={styles.itemDate}>{formatDate(r.created)}</span>} </a>
</div>
<span className={`${styles.status} ${statusClass(r.status)}`}>
{r.status || '—'}
</span>
</li> </li>
))} ))}
</ul> </ul>
@@ -11,4 +11,6 @@ export interface IMyRequest {
requestTypeId: number; requestTypeId: number;
requestTypeName: string; requestTypeName: string;
section: string; section: string;
/** Ключ формы (rptype в URL режима согласования) — поле pageUrl из approval-route API. */
formKey: string;
} }
@@ -86,6 +86,7 @@ async function getMyRequestsForType(
requestTypeId: type.id, requestTypeId: type.id,
requestTypeName: type.name, requestTypeName: type.name,
section: type.section, section: type.section,
formKey: type.pageUrl,
}; };
}) })
.filter((r) => !!tokenEmail && r.authorEmail.toLowerCase() === tokenEmail); .filter((r) => !!tokenEmail && r.authorEmail.toLowerCase() === tokenEmail);
@@ -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)
) : ( ) : (
@@ -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;
}