feat: added notifications workflow
This commit is contained in:
@@ -24,6 +24,8 @@ export type { IApproverToWrite } from './approvalList.service';
|
||||
export { ApproverService } from './approverDecision.service';
|
||||
export type { ApproverDecision } from './approverDecision.service';
|
||||
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
|
||||
export { notifyActiveStep } from './notify.service';
|
||||
export type { INotifyTarget } from './notify.service';
|
||||
export { useMatrixEditor } from './useMatrixEditor';
|
||||
export type { IUseMatrixEditor } from './useMatrixEditor';
|
||||
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { AadHttpClient } from '@microsoft/sp-http';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { FLOW_RESOURCE, FLOW_NOTIFY_URL } from '../config';
|
||||
import { ApprovalListService } from './approvalList.service';
|
||||
import { REQUEST_STATUS } from './models/statuses';
|
||||
|
||||
// Данные формы для тела запроса к потоку (постоянны для формы).
|
||||
export interface INotifyTarget {
|
||||
requestTypeId: number; // RequestTypeId (id типа заявки во внешнем API маршрутов)
|
||||
dataListName: string; // ListName (список данных заявки)
|
||||
approvalListName: string; // ApproveListName (список согласования)
|
||||
}
|
||||
|
||||
// POST в поток оповещения (Entra-триггер) через AadHttpClient — обычный fetch → 401,
|
||||
// AadHttpClient даёт токен Flow Service (и снимает вопрос CORS).
|
||||
async function postNotify(context: WebPartContext, body: Record<string, unknown>): Promise<void> {
|
||||
if (!FLOW_NOTIFY_URL) {
|
||||
return;
|
||||
}
|
||||
const client = await context.aadHttpClientFactory.getClient(FLOW_RESOURCE);
|
||||
const res = await client.post(FLOW_NOTIFY_URL, AadHttpClient.configurations.v1, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Поток оповещения: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Находит АКТИВНЫЙ шаг маршрута (На согласовании / На исполнении) и уведомляет его.
|
||||
// Вызывать после отправки заявки и после каждого положительного решения. Поток по
|
||||
// переданным полям сам находит согласующего/исполнителя и шлёт уведомление.
|
||||
// Уведомление некритично — ошибки глотаем, чтобы не ронять основной сценарий.
|
||||
export async function notifyActiveStep(
|
||||
context: WebPartContext,
|
||||
approval: ApprovalListService,
|
||||
requestId: number,
|
||||
target: INotifyTarget
|
||||
): Promise<void> {
|
||||
try {
|
||||
const rows = await approval.getForRequest(requestId);
|
||||
const active = rows.filter(
|
||||
(r) => r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution
|
||||
)[0];
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
await postNotify(context, {
|
||||
RequestTypeId: target.requestTypeId,
|
||||
RowID: active.id,
|
||||
RequestID: requestId,
|
||||
ListName: target.dataListName,
|
||||
ApproveListName: target.approvalListName,
|
||||
});
|
||||
} catch {
|
||||
// не критично — просто не отправили уведомление
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
||||
<div className={styles.matrixNote}>{emptyText}</div>
|
||||
)}
|
||||
{!loading && !error && rows.length > 0 && (
|
||||
<div className={styles.matrixScroll}>
|
||||
<table className={styles.matrix}>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -51,45 +52,57 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td>{r.who}</td>
|
||||
<td>{r.position}</td>
|
||||
<td>{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}</td>
|
||||
<td>{r.date}</td>
|
||||
{editable && (
|
||||
<td className={styles.matrixActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
disabled={i === 0}
|
||||
onClick={() => editor.moveUp(i)}
|
||||
title="Выше"
|
||||
>
|
||||
<Icon name="chevron-down" size="sm" className={styles.flipY} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
disabled={i === rows.length - 1}
|
||||
onClick={() => editor.moveDown(i)}
|
||||
title="Ниже"
|
||||
>
|
||||
<Icon name="chevron-down" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
|
||||
onClick={() => editor.remove(i)}
|
||||
title="Удалить"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
<React.Fragment key={i}>
|
||||
<tr className={r.comment ? styles.rowNoBorder : undefined}>
|
||||
<td data-label={roleLabel}>{r.who}</td>
|
||||
<td data-label="Должность">{r.position}</td>
|
||||
<td data-label="Статус">
|
||||
{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}
|
||||
</td>
|
||||
<td data-label="Дата">{r.date}</td>
|
||||
{editable && (
|
||||
<td className={styles.matrixActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
disabled={i === 0}
|
||||
onClick={() => editor.moveUp(i)}
|
||||
title="Выше"
|
||||
>
|
||||
<Icon name="chevron-down" size="sm" className={styles.flipY} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
disabled={i === rows.length - 1}
|
||||
onClick={() => editor.moveDown(i)}
|
||||
title="Ниже"
|
||||
>
|
||||
<Icon name="chevron-down" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
|
||||
onClick={() => editor.remove(i)}
|
||||
title="Удалить"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
{r.comment && (
|
||||
<tr>
|
||||
<td colSpan={editable ? 5 : 4} className={styles.commentCell}>
|
||||
<span className={styles.commentLabel}>Комментарий:</span> {r.comment}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tr>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{editable && !loading && (
|
||||
<div className={styles.matrixPicker}>
|
||||
|
||||
@@ -91,8 +91,16 @@
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
// Обёртка таблицы: на промежуточных ширинах — горизонтальный скролл вместо
|
||||
// сплющивания колонок. На телефоне таблица становится карточками (медиазапрос ниже).
|
||||
.matrixScroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.matrix {
|
||||
width: 100%;
|
||||
min-width: 480px; // колонки не сжимаются; при нехватке места .matrixScroll прокручивает
|
||||
border-collapse: collapse;
|
||||
font-size: $fs-sm;
|
||||
|
||||
@@ -113,6 +121,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Строка, у которой ниже идёт комментарий: убираем нижнюю границу, чтобы строка
|
||||
// и её комментарий читались как единый блок (граница переезжает на .commentCell).
|
||||
.rowNoBorder td {
|
||||
border-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.commentCell {
|
||||
padding: 0 8px 8px !important;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
font-size: $fs-caption;
|
||||
color: $gray-500;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap; // сохранить переносы строк из комментария
|
||||
}
|
||||
|
||||
.commentLabel {
|
||||
color: $gray-400;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.matrixActions {
|
||||
display: flex;
|
||||
gap: $sp-1;
|
||||
@@ -158,3 +186,54 @@
|
||||
.matrixPicker {
|
||||
margin-top: $sp-3;
|
||||
}
|
||||
|
||||
// ---- Мобильные экраны: таблица маршрута → карточки ----
|
||||
// Скрываем шапку, каждая строка — блок, ячейки в столбик с подписью поля
|
||||
// (из data-label). Так маршрут читается на телефоне без горизонтального скролла.
|
||||
@media (max-width: 640px) {
|
||||
.matrix {
|
||||
min-width: 0; // на телефоне не растягиваем — раскладываем в столбик
|
||||
}
|
||||
|
||||
.matrix thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.matrix tr {
|
||||
display: block;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
// Строка с комментарием: линия — на самом комментарии, не на строке.
|
||||
.rowNoBorder {
|
||||
border-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.matrix td {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
border: 0 !important;
|
||||
padding: 3px 0 !important;
|
||||
}
|
||||
|
||||
// Подпись поля перед значением (только у ячеек с data-label).
|
||||
.matrix td[data-label]::before {
|
||||
content: attr(data-label);
|
||||
flex: 0 0 44%;
|
||||
color: $gray-500;
|
||||
font-size: $fs-caption;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.matrixActions {
|
||||
justify-content: flex-start;
|
||||
padding-top: $sp-2 !important;
|
||||
}
|
||||
|
||||
.commentCell {
|
||||
display: block;
|
||||
padding: 2px 0 6px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,3 +45,16 @@ export async function uploadAttachments(
|
||||
await item.attachmentFiles.add(file.name, content);
|
||||
}
|
||||
}
|
||||
|
||||
/** Удалить вложение элемента по имени файла (для правки заявки). */
|
||||
export async function deleteAttachment(
|
||||
listTitle: string,
|
||||
itemId: number,
|
||||
fileName: string
|
||||
): Promise<void> {
|
||||
await getSP()
|
||||
.web.lists.getByTitle(listTitle)
|
||||
.items.getById(itemId)
|
||||
.attachmentFiles.getByName(fileName)
|
||||
.delete();
|
||||
}
|
||||
|
||||
@@ -17,3 +17,17 @@ export const DATA_SITE_URL = 'https://tnsukz.sharepoint.com/sites/requests';
|
||||
*/
|
||||
export const APPROVAL_API_CLIENT_ID = '9232d9e2-9807-4753-808f-07250e5474b0';
|
||||
export const APPROVAL_API_BASE_URL = 'https://api.tnsu.kz/approval-route';
|
||||
|
||||
/**
|
||||
* Power Automate: поток оповещения согласующего/исполнителя.
|
||||
* Триггер «When an HTTP request is received» защищён Entra («любой пользователь
|
||||
* тенанта»), поэтому URL без подписи (`sig`), а вызываем через AadHttpClient с
|
||||
* ресурсом Flow Service (обычный fetch → 401; так же решается CORS). Разрешение
|
||||
* «Microsoft Flow Service / Flows.Read.All» объявлено в package-solution.json и
|
||||
* одобряется в Центре администрирования SharePoint (API access) при развёртывании.
|
||||
* Шлём тело `{ RowID: <id строки списка согласования> }` — поток сам находит по
|
||||
* строке согласующего, статус и заявку и уведомляет. (Паттерн из kps-ct-form.)
|
||||
*/
|
||||
export const FLOW_RESOURCE = 'https://service.flow.microsoft.com/';
|
||||
export const FLOW_NOTIFY_URL =
|
||||
'https://eab3d2815f2beb42bf2e7ec1731708.92.environment.api.powerplatform.com:443/powerautomate/automations/direct/cu/07/workflows/b21516edf8a14287a65a39e4116dfe4f/triggers/manual/paths/invoke?api-version=1';
|
||||
|
||||
@@ -20,7 +20,7 @@ import * as React from 'react';
|
||||
// перезагрузки страницы. useRoute() подписан и на popstate (кнопка «назад»),
|
||||
// и на её собственное событие, поэтому реагирует на оба способа смены URL.
|
||||
|
||||
export type PortalMode = 'submit' | 'approve' | 'create';
|
||||
export type PortalMode = 'submit' | 'approve' | 'create' | 'edit';
|
||||
|
||||
/** Раздел бокового меню. */
|
||||
export type NavSection = 'new' | 'approvals' | 'own' | 'search';
|
||||
@@ -59,6 +59,11 @@ export function parseRoute(search: string = window.location.search): IRoute {
|
||||
if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) {
|
||||
return { mode: 'approve', nav, formKey, itemId };
|
||||
}
|
||||
// Редактирование существующей заявки (черновик/возвращена/отклонена) — открывает
|
||||
// форму на правку. nav='new', т.к. правка живёт в области подачи.
|
||||
if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) {
|
||||
return { mode: 'edit', nav: 'new', formKey, itemId };
|
||||
}
|
||||
// Создание производной заявки с предзаполнением из исходной.
|
||||
if (formKey && sourceId && !isNaN(sourceId)) {
|
||||
return { mode: 'create', nav: 'new', formKey, sourceId };
|
||||
@@ -82,6 +87,10 @@ function buildQuery(route: IRoute): string {
|
||||
params.set('rpmode', 'approve');
|
||||
params.set('rptype', route.formKey);
|
||||
params.set('rpid', String(route.itemId));
|
||||
} else if (route.mode === 'edit' && route.formKey && route.itemId) {
|
||||
params.set('rpmode', 'edit');
|
||||
params.set('rptype', route.formKey);
|
||||
params.set('rpid', String(route.itemId));
|
||||
} else if (route.mode === 'create' && route.formKey && route.sourceId) {
|
||||
params.set('rptype', route.formKey);
|
||||
params.set('rpsource', String(route.sourceId));
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
}
|
||||
|
||||
.link {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: $navy-700;
|
||||
text-decoration: none;
|
||||
|
||||
@@ -30,6 +32,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
.removeBtn {
|
||||
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: $gray-500;
|
||||
cursor: pointer;
|
||||
transition: .16s;
|
||||
|
||||
&:hover {
|
||||
border-color: $red-500;
|
||||
color: $red-500;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: $gray-500;
|
||||
font-size: $fs-sm;
|
||||
|
||||
@@ -5,10 +5,12 @@ import { IAttachmentInfo } from '../../attachments/attachments.service';
|
||||
|
||||
interface IAttachmentListProps {
|
||||
items: IAttachmentInfo[];
|
||||
/** Если задан — рядом с файлом показывается кнопка удаления (режим правки). */
|
||||
onRemove?: (fileName: string) => void;
|
||||
}
|
||||
|
||||
/** Список вложений заявки как ссылки на скачивание (режим чтения/согласующего). */
|
||||
export const AttachmentList: React.FC<IAttachmentListProps> = ({ items }) => {
|
||||
/** Список вложений заявки как ссылки на скачивание; с onRemove — ещё и удаление. */
|
||||
export const AttachmentList: React.FC<IAttachmentListProps> = ({ items, onRemove }) => {
|
||||
if (items.length === 0) {
|
||||
return <p className={styles.empty}>Вложений нет.</p>;
|
||||
}
|
||||
@@ -21,6 +23,17 @@ export const AttachmentList: React.FC<IAttachmentListProps> = ({ items }) => {
|
||||
<a href={attachment.url} target="_blank" rel="noreferrer" className={styles.link}>
|
||||
{attachment.fileName}
|
||||
</a>
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeBtn}
|
||||
title="Удалить вложение"
|
||||
aria-label={`Удалить ${attachment.fileName}`}
|
||||
onClick={() => onRemove(attachment.fileName)}
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user