Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e989c26c30 | |||
| 3684c75a0c | |||
| 3a68f2a1c3 | |||
| 687ba5a753 | |||
| 8a67737299 | |||
| ed21944628 |
@@ -52,6 +52,11 @@
|
|||||||
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 {
|
.tabsWrap {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
.join(' ');
|
.join(' ');
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const [panelCollapsed, setPanelCollapsed] = React.useState<boolean>(false);
|
||||||
|
|
||||||
const [activeCategoryId, setActiveCategoryId] = React.useState<string>(() => {
|
const [activeCategoryId, setActiveCategoryId] = React.useState<string>(() => {
|
||||||
if (route.mode === 'create' && route.formKey) {
|
if (route.mode === 'create' && route.formKey) {
|
||||||
@@ -32,19 +33,43 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
return DEFAULT_CATEGORY_ID;
|
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.layout}>
|
||||||
<div className={styles.side}>
|
<div className={`${styles.side} ${panelCollapsed ? styles.sideCollapsed : ''}`}>
|
||||||
<MyRequestsPanel context={context} />
|
<MyRequestsPanel
|
||||||
|
context={context}
|
||||||
|
collapsed={panelCollapsed}
|
||||||
|
onToggleCollapsed={() => setPanelCollapsed((v) => !v)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<main className={styles.main}>
|
<main className={styles.main}>
|
||||||
<div className={styles.tabsWrap}>
|
<div className={styles.tabsWrap}>
|
||||||
<CategoryTabs
|
<CategoryTabs
|
||||||
categories={CATEGORIES}
|
categories={CATEGORIES}
|
||||||
activeId={activeCategoryId}
|
activeId={activeCategoryId}
|
||||||
onChange={setActiveCategoryId}
|
onChange={(id) => {
|
||||||
|
setActiveCategoryId(id);
|
||||||
|
// Клик по вкладке всегда возвращает к списку плиток: в режиме
|
||||||
|
// подачи это уже делает сам SubmitFeature (сброс выбранной формы
|
||||||
|
// при смене категории), а в режиме согласования (заявка открыта
|
||||||
|
// из «Текущие заявки») route сам не меняется — выходим явно,
|
||||||
|
// чтобы поведение вкладок было одинаковым в обоих случаях.
|
||||||
|
if (route.mode === 'approve') {
|
||||||
|
navigate({ mode: 'submit' });
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{route.mode === 'approve' && route.formKey && route.itemId ? (
|
{route.mode === 'approve' && route.formKey && route.itemId ? (
|
||||||
|
|||||||
@@ -14,16 +14,68 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
||||||
@@ -40,81 +92,154 @@
|
|||||||
color: $red-500;
|
color: $red-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group + .group {
|
.section + .section {
|
||||||
margin-top: $sp-5;
|
margin-top: $sp-6;
|
||||||
|
padding-top: $sp-6;
|
||||||
|
border-top: 1px solid $gray-200;
|
||||||
}
|
}
|
||||||
|
|
||||||
.groupTitle {
|
.sectionTitle {
|
||||||
font-family: $font-serif;
|
font-family: $font-sans;
|
||||||
font-size: $fs-label;
|
font-size: $fs-caption;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: $navy-800;
|
color: $gray-500;
|
||||||
margin-bottom: $sp-2;
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
margin-bottom: $sp-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list {
|
.pager {
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $sp-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 4px;
|
||||||
gap: $sp-2;
|
margin-top: $sp-3;
|
||||||
padding: $sp-2 $sp-3;
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagerBtn {
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 6px;
|
||||||
border: 1px solid $gray-200;
|
border: 1px solid $gray-200;
|
||||||
border-radius: $r-sm;
|
border-radius: $r-sm;
|
||||||
background: $gray-50;
|
background: $white;
|
||||||
text-decoration: none;
|
color: $gray-700;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
cursor: pointer;
|
||||||
transition: .16s;
|
transition: .16s;
|
||||||
|
|
||||||
&:hover {
|
&:hover:not(:disabled) {
|
||||||
border-color: $gold-400;
|
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;
|
background: $gold-50;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemMain {
|
.cellType {
|
||||||
display: flex;
|
width: 72px;
|
||||||
flex-direction: column;
|
color: $gold-600;
|
||||||
gap: 2px;
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cellTitle {
|
||||||
|
// Ширину не задаём — делит оставшееся место с cellAuthor (table-layout: fixed).
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemTitle {
|
.titleLink {
|
||||||
|
display: block;
|
||||||
color: $gray-900;
|
color: $gray-900;
|
||||||
font-size: $fs-sm;
|
text-decoration: none;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
&:hover {
|
||||||
white-space: nowrap;
|
color: $gold-600;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemAuthor {
|
.cellAuthor {
|
||||||
color: $gray-500;
|
color: $gray-500;
|
||||||
font-size: $fs-caption;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemDate {
|
.cellDate {
|
||||||
|
width: 64px;
|
||||||
color: $gray-400;
|
color: $gray-400;
|
||||||
font-size: $fs-caption;
|
}
|
||||||
|
|
||||||
|
.cellStatus {
|
||||||
|
width: 96px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
flex-shrink: 0;
|
// inline-block, а не flex-элемент: бейдж живёт внутри ячейки таблицы и
|
||||||
padding: 2px 8px;
|
// переносится по словам (напр. «Возвращена на доработку» в узкой колонке).
|
||||||
border-radius: $r-full;
|
display: inline-block;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: $r-md;
|
||||||
font-size: $fs-caption;
|
font-size: $fs-caption;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusDraft {
|
.statusDraft {
|
||||||
|
|||||||
@@ -1,32 +1,21 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { getMyRequests } from './myRequests.service';
|
import { getLinkedTypes, getPendingApproval, searchOwnRequests } from './myRequests.service';
|
||||||
import { IMyRequest } from './models/IMyRequest';
|
import { IMyRequest } from './models/IMyRequest';
|
||||||
import { REQUEST_STATUS } from '../../shared/approval';
|
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
|
||||||
import { navigate, routeUrl } from '../../shared/routing';
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IGroup {
|
const PAGE_SIZE = 20;
|
||||||
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 {
|
function formatDate(iso: string): string {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
@@ -53,54 +42,15 @@ function statusClass(status: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Постоянная боковая панель «Мои заявки»: заявки, которые создал текущий
|
// «Ждёt вашего решения»: автор здесь — не вы, поэтому важна колонка «Автор»,
|
||||||
// пользователь, по всем типам заявок, известным approval-route API.
|
// а статус не нужен (в этой секции все заявки по определению «на согласовании»).
|
||||||
// Владелец: второй разработчик. Вся логика/данные — внутри этой папки.
|
// «Созданы вами»: автор — всегда вы, колонку не показываем, зато нужен статус.
|
||||||
// Общий UI-кит (shared/ui), pnpjs и config можно импортировать, но НЕ менять.
|
const RequestRow: React.FC<{ r: IMyRequest; variant: 'own' | 'approval' }> = ({ r, variant }) => (
|
||||||
//
|
<tr className={styles.row}>
|
||||||
// Диагностика по типам заявок (listId/siteUrl/rowCount/error) в UI не выводится —
|
<td className={styles.cellType}>{r.requestTypeName}</td>
|
||||||
// см. console.table в devtools при загрузке панели.
|
<td className={styles.cellTitle}>
|
||||||
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 (
|
|
||||||
<aside className={styles.panel} aria-label="Мои заявки">
|
|
||||||
<div className={styles.header}>
|
|
||||||
<span className={styles.title}>Мои заявки</span>
|
|
||||||
</div>
|
|
||||||
<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
|
<a
|
||||||
className={styles.item}
|
className={styles.titleLink}
|
||||||
href={routeUrl({ mode: 'approve', formKey: r.formKey, itemId: r.id })}
|
href={routeUrl({ mode: 'approve', formKey: r.formKey, itemId: r.id })}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
// Обычный левый клик — SPA-переход (без перезагрузки страницы).
|
// Обычный левый клик — SPA-переход (без перезагрузки страницы).
|
||||||
@@ -113,22 +63,311 @@ const MyRequestsPanel: React.FC<IMyRequestsPanelProps> = ({ context }) => {
|
|||||||
navigate({ mode: 'approve', formKey: r.formKey, itemId: r.id });
|
navigate({ mode: 'approve', formKey: r.formKey, itemId: r.id });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={styles.itemMain}>
|
{r.title || `#${r.id}`}
|
||||||
<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>
|
</a>
|
||||||
</li>
|
</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} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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>
|
</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 можно импортировать, но НЕ менять.
|
||||||
|
//
|
||||||
|
// «Ждёт вашего решения» перечитывается по событию 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 (
|
||||||
|
<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}>
|
||||||
|
<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 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,4 +13,6 @@ export interface IMyRequest {
|
|||||||
section: string;
|
section: string;
|
||||||
/** Ключ формы (rptype в URL режима согласования) — поле pageUrl из approval-route API. */
|
/** Ключ формы (rptype в URL режима согласования) — поле pageUrl из approval-route API. */
|
||||||
formKey: string;
|
formKey: string;
|
||||||
|
/** 'own' — заявка, созданная текущим пользователем; 'approval' — ждёт его решения. */
|
||||||
|
kind: 'own' | 'approval';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { IMyRequest } from './IMyRequest';
|
|
||||||
|
|
||||||
// Диагностика по одному типу заявки — для отладочного блока в панели «Мои заявки».
|
// Диагностика по одному типу заявки — для отладочного блока в панели «Мои заявки».
|
||||||
export interface IMyRequestsTypeDebug {
|
export interface IMyRequestsTypeDebug {
|
||||||
requestTypeId: number;
|
requestTypeId: number;
|
||||||
@@ -12,9 +10,15 @@ export interface IMyRequestsTypeDebug {
|
|||||||
rowCount?: number;
|
rowCount?: number;
|
||||||
/** Текст ошибки, если запрос к списку упал. */
|
/** Текст ошибки, если запрос к списку упал. */
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
|
||||||
|
|
||||||
export interface IMyRequestsResult {
|
/** Заголовок списка согласования, вычисленный из имени списка данных. */
|
||||||
rows: IMyRequest[];
|
approvalListTitle?: string;
|
||||||
debug: IMyRequestsTypeDebug[];
|
/** Список данных не похож на «... — данные» — список согласования не искали. */
|
||||||
|
approvalSkippedReason?: string;
|
||||||
|
/** Сколько строк маршрута (фаза «Согласование») прочитано из списка согласования. */
|
||||||
|
approvalRowCount?: number;
|
||||||
|
/** Сколько заявок сейчас ждут решения текущего пользователя. */
|
||||||
|
approvalPendingTickets?: number;
|
||||||
|
/** Текст ошибки, если запрос к списку согласования упал. */
|
||||||
|
approvalError?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,23 +3,46 @@ import '@pnp/sp/webs';
|
|||||||
import '@pnp/sp/lists';
|
import '@pnp/sp/lists';
|
||||||
import '@pnp/sp/items';
|
import '@pnp/sp/items';
|
||||||
import '@pnp/sp/site-users/web';
|
import '@pnp/sp/site-users/web';
|
||||||
|
import '@pnp/sp/search';
|
||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { getRequestTypes, IRequestType } from '../../shared/approval';
|
import { getRequestTypes, IRequestType, REQUEST_STATUS } from '../../shared/approval';
|
||||||
import { IMyRequest } from './models/IMyRequest';
|
import { IMyRequest } from './models/IMyRequest';
|
||||||
import { IMyRequestsResult, IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
|
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
|
||||||
|
|
||||||
// Заявки текущего пользователя по всем типам заявок, известным approval-route API.
|
// Список данных «... — данные» → список согласования «... — согласование»
|
||||||
// Для каждого типа с заполненными listId/siteUrl читаем список данных на его сайте
|
// (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json).
|
||||||
// и берём строки, где Author — текущий пользователь. Типы без listId/siteUrl
|
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
|
||||||
// (ещё не настроены на бэкенде) пропускаются. Ошибка по одному списку (нет прав,
|
|
||||||
// список удалён и т.п.) не должна ронять всю панель — такие типы просто дают
|
interface IApprovalStep {
|
||||||
// пустой результат. debug возвращается всегда — панель показывает его как
|
status: string;
|
||||||
// отладочную информацию (что пропущено и почему, что упало с ошибкой).
|
order?: number;
|
||||||
export async function getMyRequests(context: WebPartContext): Promise<IMyRequestsResult> {
|
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);
|
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(
|
const perType = await Promise.all(
|
||||||
types.map((t) => (t.listId && t.siteUrl ? getMyRequestsForType(context, t) : skipType(t)))
|
types.map((t) => getApprovalPendingForType(context, t, tokenEmail))
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -28,22 +51,10 @@ export async function getMyRequests(context: WebPartContext): Promise<IMyRequest
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function skipType(type: IRequestType): { rows: IMyRequest[]; debug: IMyRequestsTypeDebug } {
|
async function getApprovalPendingForType(
|
||||||
return {
|
|
||||||
rows: [],
|
|
||||||
debug: {
|
|
||||||
requestTypeId: type.id,
|
|
||||||
requestTypeName: type.name,
|
|
||||||
listId: type.listId,
|
|
||||||
siteUrl: type.siteUrl,
|
|
||||||
skipped: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getMyRequestsForType(
|
|
||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
type: IRequestType
|
type: IRequestType,
|
||||||
|
tokenEmail: string
|
||||||
): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug }> {
|
): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug }> {
|
||||||
const debug: IMyRequestsTypeDebug = {
|
const debug: IMyRequestsTypeDebug = {
|
||||||
requestTypeId: type.id,
|
requestTypeId: type.id,
|
||||||
@@ -57,25 +68,228 @@ async function getMyRequestsForType(
|
|||||||
const sp = spfi(type.siteUrl as string).using(SPFx(context));
|
const sp = spfi(type.siteUrl as string).using(SPFx(context));
|
||||||
const list = sp.web.getList(type.listId as string);
|
const list = sp.web.getList(type.listId as string);
|
||||||
|
|
||||||
const me = await sp.web.currentUser.select('Id')();
|
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);
|
||||||
|
|
||||||
const rows = (await list.items
|
// Phase не смотрим вообще — работаем только по Status.
|
||||||
.filter(`AuthorId eq ${me.Id}`)
|
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')
|
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
|
||||||
.expand('Author')
|
.expand('Author')
|
||||||
.orderBy('Id', false)
|
.top(2000)()) as Array<Record<string, unknown>>;
|
||||||
.top(200)()) as Array<Record<string, unknown>>;
|
|
||||||
|
|
||||||
debug.rowCount = rows.length;
|
const rows = ticketRows.map((r) => {
|
||||||
|
|
||||||
// Токен пользователя (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;
|
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 {
|
return {
|
||||||
id: r.Id as number,
|
id: r.Id as number,
|
||||||
title: (r.Title as string) || '',
|
title: (r.Title as string) || '',
|
||||||
@@ -87,14 +301,6 @@ async function getMyRequestsForType(
|
|||||||
requestTypeName: type.name,
|
requestTypeName: type.name,
|
||||||
section: type.section,
|
section: type.section,
|
||||||
formKey: type.pageUrl,
|
formKey: type.pageUrl,
|
||||||
|
kind,
|
||||||
};
|
};
|
||||||
})
|
|
||||||
.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 };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { RequestGrid } from '../../components/RequestGrid/RequestGrid';
|
|||||||
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;
|
||||||
@@ -60,7 +61,13 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({ context, activeCategoryI
|
|||||||
// Ленивый рендер формы: её код подтягивается только сейчас, при открытии.
|
// Ленивый рендер формы: её код подтягивается только сейчас, при открытии.
|
||||||
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,18 +39,27 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
const approval = useMemo(() => hireApprovalService(), []);
|
const approval = useMemo(() => hireApprovalService(), []);
|
||||||
const userEmail = context.pageContext.user.email;
|
const userEmail = context.pageContext.user.email;
|
||||||
const loadMatrix = useCallback(
|
const loadMatrix = useCallback(
|
||||||
() => getPlannedMatrix(context, HIRE_REQUEST_TYPE_ID, userEmail, approval),
|
() => getPlannedMatrix(context, HIRE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'),
|
||||||
[context, userEmail, approval]
|
[context, userEmail, approval]
|
||||||
);
|
);
|
||||||
const matrix = useMatrixEditor(loadMatrix, userEmail, true, true, (login) =>
|
const matrix = useMatrixEditor(loadMatrix, userEmail, true, true, (login) =>
|
||||||
approval.getJobTitle(login)
|
approval.getJobTitle(login)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Фаза исполнения: исполнители из маршрута (status='Execution'), только для чтения.
|
||||||
|
const loadExecutors = useCallback(
|
||||||
|
() => getPlannedMatrix(context, HIRE_REQUEST_TYPE_ID, userEmail, approval, 'Execution'),
|
||||||
|
[context, userEmail, approval]
|
||||||
|
);
|
||||||
|
const executors = useMatrixEditor(loadExecutors, userEmail, false, true, (login) =>
|
||||||
|
approval.getJobTitle(login)
|
||||||
|
);
|
||||||
|
|
||||||
const saving = actionStatus === 'saving';
|
const saving = actionStatus === 'saving';
|
||||||
const isValid = data.CandidateName.trim().length > 0 && data.Position.trim().length > 0;
|
const isValid = data.CandidateName.trim().length > 0 && data.Position.trim().length > 0;
|
||||||
|
|
||||||
const matrixApprovers = (): IApproverToWrite[] =>
|
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
||||||
matrix.rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign }));
|
rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign }));
|
||||||
|
|
||||||
async function run(kind: ActionKind, requestStatus: string): Promise<void> {
|
async function run(kind: ActionKind, requestStatus: string): Promise<void> {
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
@@ -61,7 +70,12 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
setActionKind(kind);
|
setActionKind(kind);
|
||||||
setActionStatus('saving');
|
setActionStatus('saving');
|
||||||
try {
|
try {
|
||||||
const id = await saveHireRequest(data, matrixApprovers(), requestStatus);
|
const id = await saveHireRequest(
|
||||||
|
data,
|
||||||
|
toApprovers(matrix.rows),
|
||||||
|
toApprovers(executors.rows),
|
||||||
|
requestStatus
|
||||||
|
);
|
||||||
await uploadAttachments(DATA_LIST_TITLE, id, files); // файлы — после создания элемента
|
await uploadAttachments(DATA_LIST_TITLE, id, files); // файлы — после создания элемента
|
||||||
setStatus(requestStatus);
|
setStatus(requestStatus);
|
||||||
setActionStatus('success');
|
setActionStatus('success');
|
||||||
@@ -115,6 +129,16 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
|
|
||||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
||||||
|
|
||||||
|
{!executors.loading && executors.rows.length > 0 && (
|
||||||
|
<ApprovalMatrix
|
||||||
|
editor={executors}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
title="Исполнение (после согласования)"
|
||||||
|
roleLabel="Исполнитель"
|
||||||
|
emptyText="Исполнители не заданы по маршруту."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormActions>
|
<FormActions>
|
||||||
<Button variant="secondary" onClick={onCancel} disabled={saving}>
|
<Button variant="secondary" onClick={onCancel} disabled={saving}>
|
||||||
Отмена
|
Отмена
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" },
|
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" },
|
||||||
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
||||||
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
||||||
{ "internalName": "Order", "title": "Порядок", "type": "Number" },
|
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
|
||||||
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" }
|
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
|
||||||
|
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
import { ApprovalListService, IApproverToWrite, writeApprovers } from '../../shared/approval';
|
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
||||||
import { IFormData } from './model';
|
import { IFormData } from './model';
|
||||||
|
|
||||||
export const DATA_LIST_TITLE = 'Прием на работу — данные';
|
export const DATA_LIST_TITLE = 'Прием на работу — данные';
|
||||||
@@ -40,16 +40,18 @@ async function writeContent(data: IFormData, status: string): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохранить заявку «на прием»: контент + маршрут согласующих со статусом.
|
* Сохранить заявку «на прием»: контент + маршрут (согласующие + исполнители) со статусом.
|
||||||
* status = «Черновик» (сохранение) или «На согласовании» (отправка).
|
* status = «Черновик» (сохранение) или «На согласовании» (отправка).
|
||||||
|
* executors может быть пустым — тогда после согласования заявка станет «Согласовано».
|
||||||
* Возвращает Id заявки.
|
* Возвращает Id заявки.
|
||||||
*/
|
*/
|
||||||
export async function saveHireRequest(
|
export async function saveHireRequest(
|
||||||
data: IFormData,
|
data: IFormData,
|
||||||
approvers: IApproverToWrite[],
|
approvers: IApproverToWrite[],
|
||||||
|
executors: IApproverToWrite[],
|
||||||
status: string
|
status: string
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const id = await writeContent(data, status);
|
const id = await writeContent(data, status);
|
||||||
await writeApprovers(hireApprovalService(), id, approvers, status);
|
await writeRoute(hireApprovalService(), id, approvers, executors, status);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,16 +62,25 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
const approval = useMemo(() => workplaceApprovalService(), []);
|
const approval = useMemo(() => workplaceApprovalService(), []);
|
||||||
const userEmail = context.pageContext.user.email;
|
const userEmail = context.pageContext.user.email;
|
||||||
const loadMatrix = useCallback(
|
const loadMatrix = useCallback(
|
||||||
() => getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval),
|
() => getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'),
|
||||||
[context, userEmail, approval]
|
[context, userEmail, approval]
|
||||||
);
|
);
|
||||||
const matrix = useMatrixEditor(loadMatrix, userEmail, true, true, (login) => approval.getJobTitle(login));
|
const matrix = useMatrixEditor(loadMatrix, userEmail, true, true, (login) => approval.getJobTitle(login));
|
||||||
|
|
||||||
|
// Фаза исполнения: исполнители из маршрута (status='Execution'), только для чтения.
|
||||||
|
const loadExecutors = useCallback(
|
||||||
|
() => getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Execution'),
|
||||||
|
[context, userEmail, approval]
|
||||||
|
);
|
||||||
|
const executors = useMatrixEditor(loadExecutors, userEmail, false, true, (login) =>
|
||||||
|
approval.getJobTitle(login)
|
||||||
|
);
|
||||||
|
|
||||||
const saving = actionStatus === 'saving';
|
const saving = actionStatus === 'saving';
|
||||||
const isValid = data.FullName.trim().length > 0 && data.Position.trim().length > 0;
|
const isValid = data.FullName.trim().length > 0 && data.Position.trim().length > 0;
|
||||||
|
|
||||||
const matrixApprovers = (): IApproverToWrite[] =>
|
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
||||||
matrix.rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign }));
|
rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign }));
|
||||||
|
|
||||||
async function run(kind: ActionKind, requestStatus: string): Promise<void> {
|
async function run(kind: ActionKind, requestStatus: string): Promise<void> {
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
@@ -82,7 +91,13 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
setActionKind(kind);
|
setActionKind(kind);
|
||||||
setActionStatus('saving');
|
setActionStatus('saving');
|
||||||
try {
|
try {
|
||||||
const id = await saveWorkplaceRequest(data, matrixApprovers(), requestStatus, sourceId);
|
const id = await saveWorkplaceRequest(
|
||||||
|
data,
|
||||||
|
toApprovers(matrix.rows),
|
||||||
|
toApprovers(executors.rows),
|
||||||
|
requestStatus,
|
||||||
|
sourceId
|
||||||
|
);
|
||||||
await uploadAttachments(DATA_LIST_TITLE, id, files);
|
await uploadAttachments(DATA_LIST_TITLE, id, files);
|
||||||
setActionStatus('success');
|
setActionStatus('success');
|
||||||
if (onSaved) {
|
if (onSaved) {
|
||||||
@@ -125,6 +140,16 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
|
|
||||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
||||||
|
|
||||||
|
{!executors.loading && executors.rows.length > 0 && (
|
||||||
|
<ApprovalMatrix
|
||||||
|
editor={executors}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
title="Исполнение (после согласования)"
|
||||||
|
roleLabel="Исполнитель"
|
||||||
|
emptyText="Исполнители не заданы по маршруту."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormActions>
|
<FormActions>
|
||||||
<Button variant="secondary" onClick={onCancel} disabled={saving}>
|
<Button variant="secondary" onClick={onCancel} disabled={saving}>
|
||||||
Отмена
|
Отмена
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Организация рабочего места — данные", "lookupShowField": "Title" },
|
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Организация рабочего места — данные", "lookupShowField": "Title" },
|
||||||
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
||||||
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
||||||
{ "internalName": "Order", "title": "Порядок", "type": "Number" },
|
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
|
||||||
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" }
|
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
|
||||||
|
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
import { ApprovalListService, IApproverToWrite, writeApprovers } from '../../shared/approval';
|
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
||||||
import { IFormData } from './model';
|
import { IFormData } from './model';
|
||||||
|
|
||||||
export const DATA_LIST_TITLE = 'Организация рабочего места — данные';
|
export const DATA_LIST_TITLE = 'Организация рабочего места — данные';
|
||||||
@@ -42,16 +42,18 @@ async function writeContent(data: IFormData, status: string, sourceHireId?: numb
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохранить заявку «орг. рабочего места»: контент + маршрут согласующих.
|
* Сохранить заявку «орг. рабочего места»: контент + маршрут (согласующие + исполнители).
|
||||||
|
* Исполнители пишутся сразу — фаза исполнения стартует автоматически после согласования.
|
||||||
* sourceHireId — если заявка создана на основе «прием».
|
* sourceHireId — если заявка создана на основе «прием».
|
||||||
*/
|
*/
|
||||||
export async function saveWorkplaceRequest(
|
export async function saveWorkplaceRequest(
|
||||||
data: IFormData,
|
data: IFormData,
|
||||||
approvers: IApproverToWrite[],
|
approvers: IApproverToWrite[],
|
||||||
|
executors: IApproverToWrite[],
|
||||||
status: string,
|
status: string,
|
||||||
sourceHireId?: number
|
sourceHireId?: number
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const id = await writeContent(data, status, sourceHireId);
|
const id = await writeContent(data, status, sourceHireId);
|
||||||
await writeApprovers(workplaceApprovalService(), id, approvers, status);
|
await writeRoute(workplaceApprovalService(), id, approvers, executors, status);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '@pnp/sp/profiles';
|
|||||||
import { IApprovalRow } from './models/IApprovalRow';
|
import { IApprovalRow } from './models/IApprovalRow';
|
||||||
import { IMatrixRow } from './models/IMatrixRow';
|
import { IMatrixRow } from './models/IMatrixRow';
|
||||||
import { ISiteUser } from './models/ISiteUser';
|
import { ISiteUser } from './models/ISiteUser';
|
||||||
|
import { APPROVAL_PHASE } from './models/statuses';
|
||||||
|
|
||||||
// Внутренние имена колонок списка согласования (создаются провижинингом).
|
// Внутренние имена колонок списка согласования (создаются провижинингом).
|
||||||
const FIELDS = {
|
const FIELDS = {
|
||||||
@@ -16,8 +17,10 @@ const FIELDS = {
|
|||||||
ticket: 'Ticket', // «Заявка» (Lookup) → TicketId
|
ticket: 'Ticket', // «Заявка» (Lookup) → TicketId
|
||||||
date: 'Date', // «Дата» (DateTime)
|
date: 'Date', // «Дата» (DateTime)
|
||||||
comment: 'Comment', // «Комментарий» (Text)
|
comment: 'Comment', // «Комментарий» (Text)
|
||||||
order: 'Order', // «Порядок» (Number)
|
order: 'StepOrder', // «Порядок» (Number). НЕ 'Order' — это зарезервированное
|
||||||
|
// скрытое системное поле SharePoint (не видно в UI списка).
|
||||||
allowReassign: 'AllowReassignToOtherApprovers', // «Можно перепоручать» (Да/Нет)
|
allowReassign: 'AllowReassignToOtherApprovers', // «Можно перепоручать» (Да/Нет)
|
||||||
|
phase: 'Phase', // «Фаза» (Choice): Согласование | Исполнение
|
||||||
};
|
};
|
||||||
|
|
||||||
// Согласующий для записи в список.
|
// Согласующий для записи в список.
|
||||||
@@ -35,12 +38,15 @@ export class ApprovalListService {
|
|||||||
return getSP().web.lists.getByTitle(this.listTitle);
|
return getSP().web.lists.getByTitle(this.listTitle);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пишет по строке на каждого согласующего с указанным статусом.
|
// Пишет по строке на каждого участника фазы с указанным статусом.
|
||||||
// Порядок (Order) = позиция в массиве.
|
// Порядок (Order) = orderStart + позиция в массиве (фазы идут подряд в одном списке:
|
||||||
|
// сначала согласующие 1..N, затем исполнители N+1..N+M).
|
||||||
public async createSteps(
|
public async createSteps(
|
||||||
requestId: number,
|
requestId: number,
|
||||||
approvers: IApproverToWrite[],
|
approvers: IApproverToWrite[],
|
||||||
status: string
|
status: string,
|
||||||
|
phase: string = APPROVAL_PHASE.approval,
|
||||||
|
orderStart: number = 1
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (let i = 0; i < approvers.length; i++) {
|
for (let i = 0; i < approvers.length; i++) {
|
||||||
const a = approvers[i];
|
const a = approvers[i];
|
||||||
@@ -51,8 +57,9 @@ export class ApprovalListService {
|
|||||||
[`${FIELDS.approver}Id`]: approverId,
|
[`${FIELDS.approver}Id`]: approverId,
|
||||||
[FIELDS.status]: status,
|
[FIELDS.status]: status,
|
||||||
[`${FIELDS.ticket}Id`]: requestId,
|
[`${FIELDS.ticket}Id`]: requestId,
|
||||||
[FIELDS.order]: i + 1,
|
[FIELDS.order]: orderStart + i,
|
||||||
[FIELDS.allowReassign]: a.allowReassign,
|
[FIELDS.allowReassign]: a.allowReassign,
|
||||||
|
[FIELDS.phase]: phase,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,7 +69,8 @@ export class ApprovalListService {
|
|||||||
requestId: number,
|
requestId: number,
|
||||||
approver: IApproverToWrite,
|
approver: IApproverToWrite,
|
||||||
order: number,
|
order: number,
|
||||||
status: string
|
status: string,
|
||||||
|
phase: string = APPROVAL_PHASE.approval
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const approverId = await this.resolveUserId(approver.email);
|
const approverId = await this.resolveUserId(approver.email);
|
||||||
await this.list().items.add({
|
await this.list().items.add({
|
||||||
@@ -72,6 +80,7 @@ export class ApprovalListService {
|
|||||||
[`${FIELDS.ticket}Id`]: requestId,
|
[`${FIELDS.ticket}Id`]: requestId,
|
||||||
[FIELDS.order]: order,
|
[FIELDS.order]: order,
|
||||||
[FIELDS.allowReassign]: approver.allowReassign,
|
[FIELDS.allowReassign]: approver.allowReassign,
|
||||||
|
[FIELDS.phase]: phase,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +88,7 @@ export class ApprovalListService {
|
|||||||
public async getForRequest(requestId: number): Promise<IApprovalRow[]> {
|
public async getForRequest(requestId: number): Promise<IApprovalRow[]> {
|
||||||
const rows = (await this.list()
|
const rows = (await this.list()
|
||||||
.items.filter(`${FIELDS.ticket}Id eq ${requestId}`)
|
.items.filter(`${FIELDS.ticket}Id eq ${requestId}`)
|
||||||
.select('Id', FIELDS.status, FIELDS.order, FIELDS.allowReassign, `${FIELDS.approver}/Id`)
|
.select('Id', FIELDS.status, FIELDS.order, FIELDS.allowReassign, FIELDS.phase, `${FIELDS.approver}/Id`)
|
||||||
.expand(FIELDS.approver)
|
.expand(FIELDS.approver)
|
||||||
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
|
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
@@ -91,6 +100,8 @@ export class ApprovalListService {
|
|||||||
approverId: approver?.Id ?? 0,
|
approverId: approver?.Id ?? 0,
|
||||||
order: (r[FIELDS.order] as number) ?? 0,
|
order: (r[FIELDS.order] as number) ?? 0,
|
||||||
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
|
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
|
||||||
|
// Пустая фаза (старые строки без колонки) трактуется как «Согласование».
|
||||||
|
phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -107,6 +118,7 @@ export class ApprovalListService {
|
|||||||
FIELDS.comment,
|
FIELDS.comment,
|
||||||
FIELDS.order,
|
FIELDS.order,
|
||||||
FIELDS.allowReassign,
|
FIELDS.allowReassign,
|
||||||
|
FIELDS.phase,
|
||||||
`${FIELDS.approver}/Title`
|
`${FIELDS.approver}/Title`
|
||||||
)
|
)
|
||||||
.expand(FIELDS.approver)
|
.expand(FIELDS.approver)
|
||||||
@@ -123,6 +135,7 @@ export class ApprovalListService {
|
|||||||
date: this.formatDate((r[FIELDS.date] as string) || ''),
|
date: this.formatDate((r[FIELDS.date] as string) || ''),
|
||||||
comment: (r[FIELDS.comment] as string) || '',
|
comment: (r[FIELDS.comment] as string) || '',
|
||||||
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
|
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
|
||||||
|
phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { APPROVAL_API_CLIENT_ID, APPROVAL_API_BASE_URL } from '../config';
|
import { APPROVAL_API_CLIENT_ID, APPROVAL_API_BASE_URL } from '../config';
|
||||||
import { IApiResponse, IRequestType, IResolvedRoute } from './models/IApprovalRouteApi';
|
import { ApprovalStatus, IApiResponse, IRequestType, IResolvedRoute } from './models/IApprovalRouteApi';
|
||||||
|
|
||||||
// Все обращения к внешнему approval-route API — только здесь.
|
// Все обращения к внешнему approval-route API — только здесь.
|
||||||
// Клиент авторизуется через AadHttpClient (SSO по текущему пользователю).
|
// Клиент авторизуется через AadHttpClient (SSO по текущему пользователю).
|
||||||
@@ -24,13 +24,17 @@ export async function getRequestTypes(context: WebPartContext): Promise<IRequest
|
|||||||
return body.data ?? [];
|
return body.data ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/template-routes/resolve — планируемый маршрут согласования для типа
|
// POST /api/template-routes/resolve — планируемый маршрут для типа заявки и
|
||||||
// заявки и пользователя. Бэкенд сам подставляет согласующих (executorEmail/position)
|
// пользователя. Бэкенд сам подставляет исполнителей шага (executorEmail/position)
|
||||||
// по ролям исходя из userEmail.
|
// по ролям исходя из userEmail. Параметр status выбирает ФАЗУ маршрута:
|
||||||
|
// 'Approval' — согласующие (кто одобряет),
|
||||||
|
// 'Execution' — исполнители (кто выполняет после согласования).
|
||||||
|
// Один и тот же маршрут, разные наборы шагов.
|
||||||
export async function resolveRoute(
|
export async function resolveRoute(
|
||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
requestTypeId: number,
|
requestTypeId: number,
|
||||||
userEmail: string
|
userEmail: string,
|
||||||
|
status: ApprovalStatus = 'Approval'
|
||||||
): Promise<IResolvedRoute> {
|
): Promise<IResolvedRoute> {
|
||||||
const c = await client(context);
|
const c = await client(context);
|
||||||
const url = `${APPROVAL_API_BASE_URL}/api/template-routes/resolve`;
|
const url = `${APPROVAL_API_BASE_URL}/api/template-routes/resolve`;
|
||||||
@@ -45,7 +49,7 @@ export async function resolveRoute(
|
|||||||
requestTypeId,
|
requestTypeId,
|
||||||
userEmail,
|
userEmail,
|
||||||
projectId: null,
|
projectId: null,
|
||||||
status: 'Approval',
|
status,
|
||||||
fieldValues: []
|
fieldValues: []
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,17 +2,22 @@ import { WebPartContext } from '@microsoft/sp-webpart-base';
|
|||||||
import { resolveRoute } from './approvalRoute.service';
|
import { resolveRoute } from './approvalRoute.service';
|
||||||
import { ApprovalListService, IApproverToWrite } from './approvalList.service';
|
import { ApprovalListService, IApproverToWrite } from './approvalList.service';
|
||||||
import { IMatrixRow } from './models/IMatrixRow';
|
import { IMatrixRow } from './models/IMatrixRow';
|
||||||
|
import { ApprovalStatus } from './models/IApprovalRouteApi';
|
||||||
|
import { APPROVAL_PHASE } from './models/statuses';
|
||||||
|
|
||||||
// Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы.
|
// Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы.
|
||||||
// Имена согласующих подтягиваются по executorEmail (бэкенд отдаёт email + должность).
|
// status выбирает фазу: 'Approval' (согласующие, по умолчанию) или 'Execution'
|
||||||
|
// (исполнители). Имена подтягиваются по executorEmail (бэкенд отдаёт email + должность).
|
||||||
export async function getPlannedMatrix(
|
export async function getPlannedMatrix(
|
||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
requestTypeId: number,
|
requestTypeId: number,
|
||||||
userEmail: string,
|
userEmail: string,
|
||||||
approval: ApprovalListService
|
approval: ApprovalListService,
|
||||||
|
status: ApprovalStatus = 'Approval'
|
||||||
): Promise<IMatrixRow[]> {
|
): Promise<IMatrixRow[]> {
|
||||||
if (!userEmail) return [];
|
if (!userEmail) return [];
|
||||||
const route = await resolveRoute(context, requestTypeId, userEmail);
|
const route = await resolveRoute(context, requestTypeId, userEmail, status);
|
||||||
|
const phase = status === 'Execution' ? APPROVAL_PHASE.execution : APPROVAL_PHASE.approval;
|
||||||
const ordered = route.steps.slice().sort((a, b) => a.orderNo - b.orderNo);
|
const ordered = route.steps.slice().sort((a, b) => a.orderNo - b.orderNo);
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
ordered.map(async (s) => ({
|
ordered.map(async (s) => ({
|
||||||
@@ -24,11 +29,12 @@ export async function getPlannedMatrix(
|
|||||||
date: '',
|
date: '',
|
||||||
comment: '',
|
comment: '',
|
||||||
allowReassign: s.allowReassignToOtherApprovers,
|
allowReassign: s.allowReassignToOtherApprovers,
|
||||||
|
phase,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Записать строки согласующих для заявки с указанным статусом.
|
// Записать строки согласующих для заявки с указанным статусом (только фаза согласования).
|
||||||
// Матрица перезаписывается целиком (для повторной отправки/черновика).
|
// Матрица перезаписывается целиком (для повторной отправки/черновика).
|
||||||
export async function writeApprovers(
|
export async function writeApprovers(
|
||||||
approval: ApprovalListService,
|
approval: ApprovalListService,
|
||||||
@@ -37,5 +43,27 @@ export async function writeApprovers(
|
|||||||
status: string
|
status: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await approval.deleteForRequest(requestId);
|
await approval.deleteForRequest(requestId);
|
||||||
await approval.createSteps(requestId, approvers, status);
|
await approval.createSteps(requestId, approvers, status, APPROVAL_PHASE.approval, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Записать ОБЕ фазы маршрута заявки: согласующих (Order 1..N) и исполнителей
|
||||||
|
// (Order N+1..N+M) в один список. Исполнители пишутся сразу при отправке —
|
||||||
|
// стороне согласующего внешний API уже не нужен, переход в фазу исполнения
|
||||||
|
// делает computeRequestStatus, когда все согласующие одобрили.
|
||||||
|
export async function writeRoute(
|
||||||
|
approval: ApprovalListService,
|
||||||
|
requestId: number,
|
||||||
|
approvers: IApproverToWrite[],
|
||||||
|
executors: IApproverToWrite[],
|
||||||
|
status: string
|
||||||
|
): Promise<void> {
|
||||||
|
await approval.deleteForRequest(requestId);
|
||||||
|
await approval.createSteps(requestId, approvers, status, APPROVAL_PHASE.approval, 1);
|
||||||
|
await approval.createSteps(
|
||||||
|
requestId,
|
||||||
|
executors,
|
||||||
|
status,
|
||||||
|
APPROVAL_PHASE.execution,
|
||||||
|
approvers.length + 1
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import '@pnp/sp/webs';
|
|||||||
import '@pnp/sp/lists';
|
import '@pnp/sp/lists';
|
||||||
import '@pnp/sp/items';
|
import '@pnp/sp/items';
|
||||||
import { ApprovalListService } from './approvalList.service';
|
import { ApprovalListService } from './approvalList.service';
|
||||||
import { REQUEST_STATUS } 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';
|
||||||
|
|
||||||
@@ -52,11 +53,13 @@ export class ApproverService {
|
|||||||
const prevOrder = before.length > 0 ? before[before.length - 1].order : current.order - 1;
|
const prevOrder = before.length > 0 ? before[before.length - 1].order : current.order - 1;
|
||||||
const newOrder = (prevOrder + current.order) / 2;
|
const newOrder = (prevOrder + current.order) / 2;
|
||||||
const position = await this.approval.getJobTitle(user.loginName);
|
const position = await this.approval.getJobTitle(user.loginName);
|
||||||
|
// Делегат наследует фазу той строки, вместо которой согласует (согласование/исполнение).
|
||||||
await this.approval.addApproverRow(
|
await this.approval.addApproverRow(
|
||||||
requestId,
|
requestId,
|
||||||
{ email: user.email, position, allowReassign: false },
|
{ email: user.email, position, allowReassign: false },
|
||||||
newOrder,
|
newOrder,
|
||||||
REQUEST_STATUS.onApproval
|
REQUEST_STATUS.onApproval,
|
||||||
|
current.phase
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,16 +75,32 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Статус заявки из строк маршрута с учётом фазы:
|
||||||
|
// 1) фаза согласования: отклонение/возврат → соответствующий статус;
|
||||||
|
// не все согласовали → «На согласовании».
|
||||||
|
// 2) согласование завершено: нет исполнителей → «Согласовано» (финал);
|
||||||
|
// исполнители есть и не все выполнили → «Выполнение»;
|
||||||
|
// все выполнили → «Закрыта».
|
||||||
|
// Строка «выполнена» исполнителем помечается тем же статусом «Согласовано»
|
||||||
|
// (действие «Выполнено» в DecisionPanel), фазы различаются колонкой Phase.
|
||||||
private computeRequestStatus(rows: IApprovalRow[]): string {
|
private computeRequestStatus(rows: IApprovalRow[]): string {
|
||||||
if (rows.some((r) => r.status === REQUEST_STATUS.rejected)) return REQUEST_STATUS.rejected;
|
const execRows = rows.filter((r) => r.phase === APPROVAL_PHASE.execution);
|
||||||
if (rows.some((r) => r.status === REQUEST_STATUS.returned)) return REQUEST_STATUS.returned;
|
const approvalRows = rows.filter((r) => r.phase !== APPROVAL_PHASE.execution);
|
||||||
if (rows.length > 0 && rows.every((r) => r.status === REQUEST_STATUS.approved)) {
|
|
||||||
return REQUEST_STATUS.approved;
|
if (approvalRows.some((r) => r.status === REQUEST_STATUS.rejected)) return REQUEST_STATUS.rejected;
|
||||||
}
|
if (approvalRows.some((r) => r.status === REQUEST_STATUS.returned)) return REQUEST_STATUS.returned;
|
||||||
return REQUEST_STATUS.onApproval;
|
|
||||||
|
const approvalDone =
|
||||||
|
approvalRows.length === 0 || approvalRows.every((r) => r.status === REQUEST_STATUS.approved);
|
||||||
|
if (!approvalDone) return REQUEST_STATUS.onApproval;
|
||||||
|
|
||||||
|
if (execRows.length === 0) return REQUEST_STATUS.approved;
|
||||||
|
if (execRows.every((r) => r.status === REQUEST_STATUS.approved)) return REQUEST_STATUS.closed;
|
||||||
|
return REQUEST_STATUS.execution;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateRequestStatus(id: number, status: string): Promise<void> {
|
private async updateRequestStatus(id: number, status: string): Promise<void> {
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
// Единая точка импорта механизма согласования.
|
// Единая точка импорта механизма согласования.
|
||||||
export { REQUEST_STATUS, REQUEST_STATUS_CHOICES } from './models/statuses';
|
export {
|
||||||
|
REQUEST_STATUS,
|
||||||
|
REQUEST_STATUS_CHOICES,
|
||||||
|
APPROVAL_PHASE,
|
||||||
|
APPROVAL_PHASE_CHOICES,
|
||||||
|
} from './models/statuses';
|
||||||
export type { ISiteUser } from './models/ISiteUser';
|
export type { ISiteUser } from './models/ISiteUser';
|
||||||
export type { IMatrixRow } from './models/IMatrixRow';
|
export type { IMatrixRow } from './models/IMatrixRow';
|
||||||
export type { IApprovalRow } from './models/IApprovalRow';
|
export type { IApprovalRow } from './models/IApprovalRow';
|
||||||
export type {
|
export type {
|
||||||
|
ApprovalStatus,
|
||||||
IRequestType,
|
IRequestType,
|
||||||
IResolvedRoute,
|
IResolvedRoute,
|
||||||
IResolvedRouteStep,
|
IResolvedRouteStep,
|
||||||
@@ -14,7 +20,7 @@ export { ApprovalListService } from './approvalList.service';
|
|||||||
export type { IApproverToWrite } from './approvalList.service';
|
export type { IApproverToWrite } from './approvalList.service';
|
||||||
export { ApproverService } from './approverDecision.service';
|
export { ApproverService } from './approverDecision.service';
|
||||||
export type { ApproverDecision } from './approverDecision.service';
|
export type { ApproverDecision } from './approverDecision.service';
|
||||||
export { getPlannedMatrix, writeApprovers } from './approvalSubmission.service';
|
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
|
||||||
export { useMatrixEditor } from './useMatrixEditor';
|
export { useMatrixEditor } from './useMatrixEditor';
|
||||||
export type { IUseMatrixEditor } from './useMatrixEditor';
|
export type { IUseMatrixEditor } from './useMatrixEditor';
|
||||||
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
||||||
|
|||||||
@@ -34,4 +34,7 @@ export interface IResolvedRouteStep {
|
|||||||
executorEmail: string | null;
|
executorEmail: string | null;
|
||||||
position: string | null;
|
position: string | null;
|
||||||
allowReassignToOtherApprovers: boolean;
|
allowReassignToOtherApprovers: boolean;
|
||||||
|
// Предупреждение маршрута по этому шагу (например, роль не разрешилась в
|
||||||
|
// конкретного человека) — приходит от API, показываем автору при желании.
|
||||||
|
warning: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ export interface IApprovalRow {
|
|||||||
approverId: number; // Id пользователя сайта в поле «Согласующий»
|
approverId: number; // Id пользователя сайта в поле «Согласующий»
|
||||||
order: number; // колонка Order (очередь согласования)
|
order: number; // колонка Order (очередь согласования)
|
||||||
allowReassign: boolean; // AllowReassignToOtherApprovers
|
allowReassign: boolean; // AllowReassignToOtherApprovers
|
||||||
|
phase: string; // Phase: «Согласование» | «Исполнение» (см. APPROVAL_PHASE)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ export interface IMatrixRow {
|
|||||||
date: string; // дата решения (дд.мм.гггг) или ''
|
date: string; // дата решения (дд.мм.гггг) или ''
|
||||||
comment: string;
|
comment: string;
|
||||||
allowReassign: boolean; // может ли этот согласующий добавить делеганта
|
allowReassign: boolean; // может ли этот согласующий добавить делеганта
|
||||||
|
phase?: string; // фаза строки: «Согласование» | «Исполнение» (см. APPROVAL_PHASE)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,3 +23,18 @@ export const REQUEST_STATUS_CHOICES: string[] = [
|
|||||||
REQUEST_STATUS.execution,
|
REQUEST_STATUS.execution,
|
||||||
REQUEST_STATUS.closed,
|
REQUEST_STATUS.closed,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Фаза строки в списке маршрута: согласование (кто одобряет) и исполнение
|
||||||
|
// (кто выполняет после согласования). Обе фазы живут в одном списке «— согласование»,
|
||||||
|
// строки различаются колонкой Phase. Значения приходят из внешнего API как
|
||||||
|
// Approval/Execution, но в списке храним по-русски для читаемости колонки.
|
||||||
|
export const APPROVAL_PHASE = {
|
||||||
|
approval: 'Согласование',
|
||||||
|
execution: 'Исполнение',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Значения Choice-колонки Phase при провижининге.
|
||||||
|
export const APPROVAL_PHASE_CHOICES: string[] = [
|
||||||
|
APPROVAL_PHASE.approval,
|
||||||
|
APPROVAL_PHASE.execution,
|
||||||
|
];
|
||||||
|
|||||||
@@ -8,25 +8,40 @@ import { UserPicker } from './UserPicker';
|
|||||||
export interface IApprovalMatrixProps {
|
export interface IApprovalMatrixProps {
|
||||||
editor: IUseMatrixEditor;
|
editor: IUseMatrixEditor;
|
||||||
search: (query: string) => Promise<ISiteUser[]>;
|
search: (query: string) => Promise<ISiteUser[]>;
|
||||||
|
/** Заголовок блока. По умолчанию — «Маршрут согласования». */
|
||||||
|
title?: string;
|
||||||
|
/** Подпись роли в шапке таблицы. По умолчанию — «Согласующий». */
|
||||||
|
roleLabel?: string;
|
||||||
|
/** Текст, когда участников нет. По умолчанию — «Согласующие не заданы.». */
|
||||||
|
emptyText?: string;
|
||||||
|
/** Плейсхолдер пикера добавления (только если editor.editable). */
|
||||||
|
pickerPlaceholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Таблица маршрута согласования + (если editable) правка порядка/состава. */
|
/** Таблица маршрута (согласование или исполнение) + (если editable) правка состава. */
|
||||||
export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({ editor, search }) => {
|
export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
||||||
|
editor,
|
||||||
|
search,
|
||||||
|
title = 'Маршрут согласования',
|
||||||
|
roleLabel = 'Согласующий',
|
||||||
|
emptyText = 'Согласующие не заданы.',
|
||||||
|
pickerPlaceholder = 'Добавить согласующего: начните вводить ФИО…',
|
||||||
|
}) => {
|
||||||
const { rows, loading, error, editable } = editor;
|
const { rows, loading, error, editable } = editor;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.matrixWrap}>
|
<div className={styles.matrixWrap}>
|
||||||
<div className={styles.matrixTitle}>Маршрут согласования</div>
|
<div className={styles.matrixTitle}>{title}</div>
|
||||||
{loading && <div className={styles.matrixNote}>Загрузка маршрута…</div>}
|
{loading && <div className={styles.matrixNote}>Загрузка маршрута…</div>}
|
||||||
{!loading && error && <div className={styles.matrixNote}>Маршрут недоступен: {error}</div>}
|
{!loading && error && <div className={styles.matrixNote}>Маршрут недоступен: {error}</div>}
|
||||||
{!loading && !error && rows.length === 0 && (
|
{!loading && !error && rows.length === 0 && (
|
||||||
<div className={styles.matrixNote}>Согласующие не заданы.</div>
|
<div className={styles.matrixNote}>{emptyText}</div>
|
||||||
)}
|
)}
|
||||||
{!loading && !error && rows.length > 0 && (
|
{!loading && !error && rows.length > 0 && (
|
||||||
<table className={styles.matrix}>
|
<table className={styles.matrix}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Согласующий</th>
|
<th>{roleLabel}</th>
|
||||||
<th>Должность</th>
|
<th>Должность</th>
|
||||||
<th>Статус</th>
|
<th>Статус</th>
|
||||||
<th>Дата</th>
|
<th>Дата</th>
|
||||||
@@ -77,11 +92,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({ editor, search
|
|||||||
)}
|
)}
|
||||||
{editable && !loading && (
|
{editable && !loading && (
|
||||||
<div className={styles.matrixPicker}>
|
<div className={styles.matrixPicker}>
|
||||||
<UserPicker
|
<UserPicker search={search} onPick={editor.addApprover} placeholder={pickerPlaceholder} />
|
||||||
search={search}
|
|
||||||
onPick={editor.addApprover}
|
|
||||||
placeholder="Добавить согласующего: начните вводить ФИО…"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import styles from './DecisionPanel.module.scss';
|
import styles from './DecisionPanel.module.scss';
|
||||||
import { Button, Alert, FormField, TextArea, Icon } from '../../ui';
|
import { Button, Alert, FormField, TextArea, Icon } from '../../ui';
|
||||||
import { ApproverService, ApproverDecision } from '../approverDecision.service';
|
import { ApproverService, ApproverDecision } from '../approverDecision.service';
|
||||||
|
import { APPROVAL_PHASE } from '../models/statuses';
|
||||||
|
|
||||||
interface IDecisionPanelProps {
|
interface IDecisionPanelProps {
|
||||||
service: ApproverService;
|
service: ApproverService;
|
||||||
@@ -17,6 +18,7 @@ interface IDecisionPanelProps {
|
|||||||
export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestId, onDecided }) => {
|
export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestId, onDecided }) => {
|
||||||
const [checking, setChecking] = useState<boolean>(true);
|
const [checking, setChecking] = useState<boolean>(true);
|
||||||
const [rowId, setRowId] = useState<number | undefined>(undefined);
|
const [rowId, setRowId] = useState<number | undefined>(undefined);
|
||||||
|
const [phase, setPhase] = useState<string>(APPROVAL_PHASE.approval);
|
||||||
const [comment, setComment] = useState<string>('');
|
const [comment, setComment] = useState<string>('');
|
||||||
const [submitting, setSubmitting] = useState<boolean>(false);
|
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||||
const [resultStatus, setResultStatus] = useState<string | undefined>(undefined);
|
const [resultStatus, setResultStatus] = useState<string | undefined>(undefined);
|
||||||
@@ -30,6 +32,9 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestI
|
|||||||
.then((row) => {
|
.then((row) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
setRowId(row?.id);
|
setRowId(row?.id);
|
||||||
|
if (row) {
|
||||||
|
setPhase(row.phase);
|
||||||
|
}
|
||||||
setChecking(false);
|
setChecking(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -67,14 +72,19 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestI
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isExecution = phase === APPROVAL_PHASE.execution;
|
||||||
|
|
||||||
if (checking) {
|
if (checking) {
|
||||||
return <div className={styles.state}>Проверяем вашу очередь на согласование…</div>;
|
return <div className={styles.state}>Проверяем вашу очередь…</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resultStatus) {
|
if (resultStatus) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.panel}>
|
<div className={styles.panel}>
|
||||||
<Alert variant="ok">Решение сохранено. Статус заявки: «{resultStatus}».</Alert>
|
<Alert variant="ok">
|
||||||
|
{isExecution ? 'Отмечено как выполнено' : 'Решение сохранено'}. Статус заявки: «
|
||||||
|
{resultStatus}».
|
||||||
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -82,9 +92,33 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestI
|
|||||||
if (rowId === undefined) {
|
if (rowId === undefined) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.panel}>
|
<div className={styles.panel}>
|
||||||
<Alert variant="warn">
|
<Alert variant="warn">Сейчас не ваша очередь, либо заявка уже обработана.</Alert>
|
||||||
Сейчас не ваша очередь на согласование, либо заявка уже обработана.
|
</div>
|
||||||
</Alert>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фаза исполнения: одно действие «Выполнено» (без обязательного комментария),
|
||||||
|
// строка помечается как «Согласовано» → computeRequestStatus переведёт заявку в
|
||||||
|
// «Выполнение»/«Закрыта».
|
||||||
|
if (isExecution) {
|
||||||
|
return (
|
||||||
|
<div className={styles.panel}>
|
||||||
|
<div className={styles.title}>Исполнение</div>
|
||||||
|
{error && <Alert variant="err">{error}</Alert>}
|
||||||
|
<FormField label="Комментарий" htmlFor="decisionComment">
|
||||||
|
<TextArea
|
||||||
|
id="decisionComment"
|
||||||
|
value={comment}
|
||||||
|
onChange={setComment}
|
||||||
|
placeholder="Комментарий (необязательно)"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Button variant="accent" disabled={submitting} onClick={() => decide('approve')}>
|
||||||
|
<Icon name="check" size="sm" />
|
||||||
|
Выполнено
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user