теперь заявки на согласование тоже пишуться
This commit is contained in:
@@ -40,6 +40,22 @@
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
.section + .section {
|
||||
margin-top: $sp-6;
|
||||
padding-top: $sp-6;
|
||||
border-top: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-caption;
|
||||
font-weight: 600;
|
||||
color: $gray-500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
margin-bottom: $sp-4;
|
||||
}
|
||||
|
||||
.group + .group {
|
||||
margin-top: $sp-5;
|
||||
}
|
||||
|
||||
@@ -53,8 +53,52 @@ function statusClass(status: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Постоянная боковая панель «Мои заявки»: заявки, которые создал текущий
|
||||
// пользователь, по всем типам заявок, известным approval-route API.
|
||||
const RequestItem: React.FC<{ r: IMyRequest }> = ({ r }) => (
|
||||
<li>
|
||||
<a
|
||||
className={styles.item}
|
||||
href={routeUrl({ mode: 'approve', formKey: r.formKey, itemId: r.id })}
|
||||
onClick={(e) => {
|
||||
// Обычный левый клик — SPA-переход (без перезагрузки страницы).
|
||||
// Ctrl/Cmd/Shift/среднюю кнопку — не трогаем, пусть браузер сам
|
||||
// откроет в новой вкладке по href.
|
||||
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
navigate({ mode: 'approve', formKey: r.formKey, itemId: r.id });
|
||||
}}
|
||||
>
|
||||
<div className={styles.itemMain}>
|
||||
<span className={styles.itemTitle}>{r.title || `#${r.id}`}</span>
|
||||
<span className={styles.itemAuthor}>
|
||||
{r.authorEmail || r.authorName || '— автор не определён —'}
|
||||
</span>
|
||||
{r.created && <span className={styles.itemDate}>{formatDate(r.created)}</span>}
|
||||
</div>
|
||||
<span className={`${styles.status} ${statusClass(r.status)}`}>{r.status || '—'}</span>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
|
||||
const RequestGroups: React.FC<{ rows: IMyRequest[] }> = ({ rows }) => (
|
||||
<>
|
||||
{groupByType(rows).map((group) => (
|
||||
<div className={styles.group} key={group.requestTypeName}>
|
||||
<div className={styles.groupTitle}>{group.requestTypeName}</div>
|
||||
<ul className={styles.list}>
|
||||
{group.rows.map((r) => (
|
||||
<RequestItem r={r} key={`${r.requestTypeId}-${r.id}`} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
// Постоянная боковая панель «Текущие заявки»: заявки, созданные текущим
|
||||
// пользователем, и заявки, ждущие сейчас его решения как согласующего —
|
||||
// по всем типам заявок, известным approval-route API.
|
||||
// Владелец: второй разработчик. Вся логика/данные — внутри этой папки.
|
||||
// Общий UI-кит (shared/ui), pnpjs и config можно импортировать, но НЕ менять.
|
||||
//
|
||||
@@ -80,55 +124,34 @@ const MyRequestsPanel: React.FC<IMyRequestsPanelProps> = ({ context }) => {
|
||||
};
|
||||
}, [context]);
|
||||
|
||||
const groups = rows ? groupByType(rows) : [];
|
||||
const pendingApproval = rows ? rows.filter((r) => r.kind === 'approval') : [];
|
||||
const own = rows ? rows.filter((r) => r.kind === 'own') : [];
|
||||
|
||||
return (
|
||||
<aside className={styles.panel} aria-label="Мои заявки">
|
||||
<aside className={styles.panel} aria-label="Текущие заявки">
|
||||
<div className={styles.header}>
|
||||
<span className={styles.title}>Мои заявки</span>
|
||||
<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>
|
||||
<div className={styles.empty}>Нет ни ваших заявок, ни заявок на ваше согласование.</div>
|
||||
)}
|
||||
{groups.map((group) => (
|
||||
<div className={styles.group} key={group.requestTypeName}>
|
||||
<div className={styles.groupTitle}>{group.requestTypeName}</div>
|
||||
<ul className={styles.list}>
|
||||
{group.rows.map((r) => (
|
||||
<li key={`${r.requestTypeId}-${r.id}`}>
|
||||
<a
|
||||
className={styles.item}
|
||||
href={routeUrl({ mode: 'approve', formKey: r.formKey, itemId: r.id })}
|
||||
onClick={(e) => {
|
||||
// Обычный левый клик — SPA-переход (без перезагрузки страницы).
|
||||
// Ctrl/Cmd/Shift/среднюю кнопку — не трогаем, пусть браузер сам
|
||||
// откроет в новой вкладке по href.
|
||||
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
navigate({ mode: 'approve', formKey: r.formKey, itemId: r.id });
|
||||
}}
|
||||
>
|
||||
<div className={styles.itemMain}>
|
||||
<span className={styles.itemTitle}>{r.title || `#${r.id}`}</span>
|
||||
<span className={styles.itemAuthor}>
|
||||
{r.authorEmail || r.authorName || '— автор не определён —'}
|
||||
</span>
|
||||
{r.created && <span className={styles.itemDate}>{formatDate(r.created)}</span>}
|
||||
</div>
|
||||
<span className={`${styles.status} ${statusClass(r.status)}`}>
|
||||
{r.status || '—'}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{pendingApproval.length > 0 && (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>Ждёт вашего решения</div>
|
||||
<RequestGroups rows={pendingApproval} />
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
{own.length > 0 && (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>Созданы вами</div>
|
||||
<RequestGroups rows={own} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -13,4 +13,6 @@ export interface IMyRequest {
|
||||
section: string;
|
||||
/** Ключ формы (rptype в URL режима согласования) — поле pageUrl из approval-route API. */
|
||||
formKey: string;
|
||||
/** 'own' — заявка, созданная текущим пользователем; 'approval' — ждёт его решения. */
|
||||
kind: 'own' | 'approval';
|
||||
}
|
||||
|
||||
@@ -12,6 +12,17 @@ export interface IMyRequestsTypeDebug {
|
||||
rowCount?: number;
|
||||
/** Текст ошибки, если запрос к списку упал. */
|
||||
error?: string;
|
||||
|
||||
/** Заголовок списка согласования, вычисленный из имени списка данных. */
|
||||
approvalListTitle?: string;
|
||||
/** Список данных не похож на «... — данные» — список согласования не искали. */
|
||||
approvalSkippedReason?: string;
|
||||
/** Сколько строк маршрута (фаза «Согласование») прочитано из списка согласования. */
|
||||
approvalRowCount?: number;
|
||||
/** Сколько заявок сейчас ждут решения текущего пользователя. */
|
||||
approvalPendingTickets?: number;
|
||||
/** Текст ошибки, если запрос к списку согласования упал. */
|
||||
approvalError?: string;
|
||||
}
|
||||
|
||||
export interface IMyRequestsResult {
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
import { spfi, SPFx } from '@pnp/sp';
|
||||
import { spfi, SPFI, SPFx } from '@pnp/sp';
|
||||
import { IList } from '@pnp/sp/lists';
|
||||
import '@pnp/sp/webs';
|
||||
import '@pnp/sp/lists';
|
||||
import '@pnp/sp/items';
|
||||
import '@pnp/sp/site-users/web';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { getRequestTypes, IRequestType } from '../../shared/approval';
|
||||
import { getRequestTypes, IRequestType, REQUEST_STATUS } from '../../shared/approval';
|
||||
import { IMyRequest } from './models/IMyRequest';
|
||||
import { IMyRequestsResult, IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
|
||||
|
||||
// Заявки текущего пользователя по всем типам заявок, известным approval-route API.
|
||||
// Для каждого типа с заполненными listId/siteUrl читаем список данных на его сайте
|
||||
// и берём строки, где Author — текущий пользователь. Типы без listId/siteUrl
|
||||
// (ещё не настроены на бэкенде) пропускаются. Ошибка по одному списку (нет прав,
|
||||
// список удалён и т.п.) не должна ронять всю панель — такие типы просто дают
|
||||
// пустой результат. debug возвращается всегда — панель показывает его как
|
||||
// отладочную информацию (что пропущено и почему, что упало с ошибкой).
|
||||
// Список данных «... — данные» → список согласования «... — согласование»
|
||||
// (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json).
|
||||
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
|
||||
|
||||
interface IApprovalStep {
|
||||
status: string;
|
||||
order?: number;
|
||||
approverEmail: string;
|
||||
}
|
||||
|
||||
// Заявки текущего пользователя по всем типам заявок, известным approval-route API:
|
||||
// созданные им самим (kind: 'own') и ждущие сейчас его решения как согласующего
|
||||
// (kind: 'approval'). Для каждого типа с заполненными listId/siteUrl читаем список
|
||||
// данных на его сайте; список согласования ищем по имени списка данных (суффикс
|
||||
// «— данные» → «— согласование»). Типы без listId/siteUrl (ещё не настроены на
|
||||
// бэкенде) пропускаются. Ошибка по одному списку не должна ронять всю панель —
|
||||
// такой тип просто даёт пустой результат. debug возвращается всегда — панель
|
||||
// не показывает его в UI, но пишет в console.table при загрузке.
|
||||
export async function getMyRequests(context: WebPartContext): Promise<IMyRequestsResult> {
|
||||
const types = await getRequestTypes(context);
|
||||
|
||||
@@ -58,43 +70,174 @@ async function getMyRequestsForType(
|
||||
const list = sp.web.getList(type.listId as string);
|
||||
|
||||
const me = await sp.web.currentUser.select('Id')();
|
||||
|
||||
const rows = (await list.items
|
||||
.filter(`AuthorId eq ${me.Id}`)
|
||||
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
|
||||
.expand('Author')
|
||||
.orderBy('Id', false)
|
||||
.top(200)()) as Array<Record<string, unknown>>;
|
||||
|
||||
debug.rowCount = rows.length;
|
||||
|
||||
// Токен пользователя (context.pageContext.user.email) — источник правды для
|
||||
// сравнения с Author/EMail списка. Не совпало (напр. Author/EMail пуст на
|
||||
// профиле сайта) — строку не показываем, а не показываем что попало.
|
||||
const tokenEmail = (context.pageContext.user.email || '').toLowerCase();
|
||||
|
||||
const mapped = rows
|
||||
.map((r) => {
|
||||
const author = r.Author as { EMail?: string; Title?: string } | undefined;
|
||||
return {
|
||||
id: r.Id as number,
|
||||
title: (r.Title as string) || '',
|
||||
status: (r.Status as string) || '',
|
||||
authorEmail: author?.EMail || '',
|
||||
authorName: author?.Title || '',
|
||||
created: (r.Created as string) || '',
|
||||
requestTypeId: type.id,
|
||||
requestTypeName: type.name,
|
||||
section: type.section,
|
||||
formKey: type.pageUrl,
|
||||
};
|
||||
})
|
||||
.filter((r) => !!tokenEmail && r.authorEmail.toLowerCase() === tokenEmail);
|
||||
const ownRows = await getOwnRows(list, type, me.Id, tokenEmail, debug);
|
||||
const pendingRows = await getApprovalPendingRows(sp, list, type, tokenEmail, debug);
|
||||
|
||||
return { rows: mapped, debug };
|
||||
return { rows: ownRows.concat(pendingRows), debug };
|
||||
} catch (e) {
|
||||
debug.error = (e as Error).message;
|
||||
console.error(`Мои заявки: не удалось прочитать список типа «${type.name}»:`, debug.error);
|
||||
return { rows: [], debug };
|
||||
}
|
||||
}
|
||||
|
||||
// Заявки, созданные текущим пользователем (Author = токен).
|
||||
async function getOwnRows(
|
||||
list: IList,
|
||||
type: IRequestType,
|
||||
myId: number,
|
||||
tokenEmail: string,
|
||||
debug: IMyRequestsTypeDebug
|
||||
): Promise<IMyRequest[]> {
|
||||
const rows = (await list.items
|
||||
.filter(`AuthorId eq ${myId}`)
|
||||
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
|
||||
.expand('Author')
|
||||
.orderBy('Id', false)
|
||||
.top(200)()) as Array<Record<string, unknown>>;
|
||||
|
||||
debug.rowCount = rows.length;
|
||||
|
||||
// Токен пользователя — источник правды для сравнения с Author/EMail списка.
|
||||
// Не совпало (напр. Author/EMail пуст на профиле сайта) — строку не показываем.
|
||||
return rows
|
||||
.map((r) => {
|
||||
const author = r.Author as { EMail?: string; Title?: string } | undefined;
|
||||
return toMyRequest(r, author, type, 'own');
|
||||
})
|
||||
.filter((r) => !!tokenEmail && r.authorEmail.toLowerCase() === tokenEmail);
|
||||
}
|
||||
|
||||
// Заявки, где сейчас очередь текущего пользователя как согласующего.
|
||||
async function getApprovalPendingRows(
|
||||
sp: SPFI,
|
||||
list: IList,
|
||||
type: IRequestType,
|
||||
tokenEmail: string,
|
||||
debug: IMyRequestsTypeDebug
|
||||
): Promise<IMyRequest[]> {
|
||||
try {
|
||||
const dataListInfo = await list.select('Title')();
|
||||
const dataTitle = (dataListInfo.Title as string) || '';
|
||||
if (!DATA_LIST_SUFFIX.test(dataTitle)) {
|
||||
debug.approvalSkippedReason = `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`;
|
||||
return [];
|
||||
}
|
||||
const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
|
||||
debug.approvalListTitle = approvalListTitle;
|
||||
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
|
||||
|
||||
// Phase не смотрим вообще — работаем только по Status.
|
||||
const stepRows = (await approvalList.items
|
||||
.select('Id', 'StepOrder', 'Status', 'TicketId', 'Approver/EMail')
|
||||
.expand('Approver')
|
||||
.top(2000)()) as Array<Record<string, unknown>>;
|
||||
console.log(
|
||||
`[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», элементов в списке: ${stepRows.length}, ваш email из токена: «${tokenEmail}»`
|
||||
);
|
||||
console.table(
|
||||
stepRows.map((r) => ({
|
||||
Id: r.Id,
|
||||
TicketId: r.TicketId,
|
||||
StepOrder: r.StepOrder,
|
||||
Status: r.Status,
|
||||
'Approver/EMail': (r.Approver as { EMail?: string } | undefined)?.EMail,
|
||||
}))
|
||||
);
|
||||
|
||||
debug.approvalRowCount = stepRows.length;
|
||||
|
||||
const byTicket = new Map<number, IApprovalStep[]>();
|
||||
stepRows.forEach((r) => {
|
||||
const ticketId = r.TicketId as number | undefined;
|
||||
if (!ticketId) return;
|
||||
const approver = r.Approver as { EMail?: string } | undefined;
|
||||
const steps = byTicket.get(ticketId) || [];
|
||||
steps.push({
|
||||
status: (r.Status as string) || '',
|
||||
order: typeof r.StepOrder === 'number' ? (r.StepOrder as number) : undefined,
|
||||
approverEmail: (approver?.EMail || '').toLowerCase(),
|
||||
});
|
||||
byTicket.set(ticketId, steps);
|
||||
});
|
||||
|
||||
const myTicketIds: number[] = [];
|
||||
const byTicketDebug: Array<Record<string, unknown>> = [];
|
||||
byTicket.forEach((steps, ticketId) => {
|
||||
const mine = isMyTurn(steps, tokenEmail);
|
||||
byTicketDebug.push({ TicketId: ticketId, isMyTurn: mine, steps: JSON.stringify(steps) });
|
||||
if (mine) myTicketIds.push(ticketId);
|
||||
});
|
||||
console.table(byTicketDebug);
|
||||
|
||||
debug.approvalPendingTickets = myTicketIds.length;
|
||||
if (myTicketIds.length === 0) return [];
|
||||
|
||||
const ticketFilter = myTicketIds.map((id) => `Id eq ${id}`).join(' or ');
|
||||
const ticketRows = (await list.items
|
||||
.filter(ticketFilter)
|
||||
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
|
||||
.expand('Author')
|
||||
.top(2000)()) as Array<Record<string, unknown>>;
|
||||
|
||||
return ticketRows.map((r) => {
|
||||
const author = r.Author as { EMail?: string; Title?: string } | undefined;
|
||||
return toMyRequest(r, author, type, 'approval');
|
||||
});
|
||||
} catch (e) {
|
||||
debug.approvalError = (e as Error).message;
|
||||
console.error(
|
||||
`Мои заявки: не удалось прочитать согласование для «${type.name}»:`,
|
||||
debug.approvalError
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Сейчас ли очередь пользователя с данным 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);
|
||||
}
|
||||
|
||||
function toMyRequest(
|
||||
r: Record<string, unknown>,
|
||||
author: { EMail?: string; Title?: string } | undefined,
|
||||
type: IRequestType,
|
||||
kind: 'own' | 'approval'
|
||||
): IMyRequest {
|
||||
return {
|
||||
id: r.Id as number,
|
||||
title: (r.Title as string) || '',
|
||||
status: (r.Status as string) || '',
|
||||
authorEmail: author?.EMail || '',
|
||||
authorName: author?.Title || '',
|
||||
created: (r.Created as string) || '',
|
||||
requestTypeId: type.id,
|
||||
requestTypeName: type.name,
|
||||
section: type.section,
|
||||
formKey: type.pageUrl,
|
||||
kind,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user