feat: request portal — bespoke forms + approval mechanism

This commit is contained in:
Тимур Городецкий
2026-08-03 09:35:14 +05:00
commit bc91c9861d
110 changed files with 30562 additions and 0 deletions
@@ -0,0 +1,219 @@
import { getSP } from '../pnp/pnpjs';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import { IList } from '@pnp/sp/lists';
import '@pnp/sp/items';
import '@pnp/sp/site-users/web';
import '@pnp/sp/profiles';
import { IApprovalRow } from './models/IApprovalRow';
import { IMatrixRow } from './models/IMatrixRow';
import { ISiteUser } from './models/ISiteUser';
// Внутренние имена колонок списка согласования (создаются провижинингом).
const FIELDS = {
approver: 'Approver', // «Согласующий» (User) → ApproverId
status: 'Status', // «Статус» (Choice)
ticket: 'Ticket', // «Заявка» (Lookup) → TicketId
date: 'Date', // «Дата» (DateTime)
comment: 'Comment', // «Комментарий» (Text)
order: 'Order', // «Порядок» (Number)
allowReassign: 'AllowReassignToOtherApprovers', // «Можно перепоручать» (Да/Нет)
};
// Согласующий для записи в список.
export interface IApproverToWrite {
email: string;
position: string;
allowReassign: boolean;
}
// Все запросы к списку «Согласование — <форма>». listTitle задаётся формой.
export class ApprovalListService {
constructor(private readonly listTitle: string) {}
private list(): IList {
return getSP().web.lists.getByTitle(this.listTitle);
}
// Пишет по строке на каждого согласующего с указанным статусом.
// Порядок (Order) = позиция в массиве.
public async createSteps(
requestId: number,
approvers: IApproverToWrite[],
status: string
): Promise<void> {
for (let i = 0; i < approvers.length; i++) {
const a = approvers[i];
if (!a.email) continue;
const approverId = await this.resolveUserId(a.email);
await this.list().items.add({
Title: a.position || a.email,
[`${FIELDS.approver}Id`]: approverId,
[FIELDS.status]: status,
[`${FIELDS.ticket}Id`]: requestId,
[FIELDS.order]: i + 1,
[FIELDS.allowReassign]: a.allowReassign,
});
}
}
// Добавляет одну строку согласования с заданным Order (для делегата).
public async addApproverRow(
requestId: number,
approver: IApproverToWrite,
order: number,
status: string
): Promise<void> {
const approverId = await this.resolveUserId(approver.email);
await this.list().items.add({
Title: approver.position || approver.email,
[`${FIELDS.approver}Id`]: approverId,
[FIELDS.status]: status,
[`${FIELDS.ticket}Id`]: requestId,
[FIELDS.order]: order,
[FIELDS.allowReassign]: approver.allowReassign,
});
}
// Строки согласования по заявке, в порядке согласования (по Order).
public async getForRequest(requestId: number): Promise<IApprovalRow[]> {
const rows = (await this.list()
.items.filter(`${FIELDS.ticket}Id eq ${requestId}`)
.select('Id', FIELDS.status, FIELDS.order, FIELDS.allowReassign, `${FIELDS.approver}/Id`)
.expand(FIELDS.approver)
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
return rows.map((r) => {
const approver = r[FIELDS.approver] as { Id?: number } | undefined;
return {
id: r.Id as number,
status: (r[FIELDS.status] as string) || '',
approverId: approver?.Id ?? 0,
order: (r[FIELDS.order] as number) ?? 0,
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
};
});
}
// Строки согласования для отображения матрицы (ФИО, должность, статус, дата, комментарий).
public async getMatrixForRequest(requestId: number): Promise<IMatrixRow[]> {
const rows = (await this.list()
.items.filter(`${FIELDS.ticket}Id eq ${requestId}`)
.select(
'Id',
'Title',
FIELDS.status,
FIELDS.date,
FIELDS.comment,
FIELDS.order,
FIELDS.allowReassign,
`${FIELDS.approver}/Title`
)
.expand(FIELDS.approver)
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
return rows.map((r, i) => {
const approver = r[FIELDS.approver] as { Title?: string } | undefined;
return {
order: (r[FIELDS.order] as number) ?? i + 1,
who: approver?.Title || '',
email: '',
position: (r.Title as string) || '',
status: (r[FIELDS.status] as string) || '',
date: this.formatDate((r[FIELDS.date] as string) || ''),
comment: (r[FIELDS.comment] as string) || '',
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
};
});
}
// Поиск пользователей сайта по части имени — для пикера согласующего.
public async searchUsers(query: string): Promise<ISiteUser[]> {
const q = query.trim();
if (q.length < 2) return [];
const escaped = q.replace(/'/g, "''");
const users = (await getSP()
.web.siteUsers.filter(`substringof('${escaped}',Title) and PrincipalType eq 1`)
.select('Id', 'Title', 'Email', 'LoginName')
.top(10)()) as Array<Record<string, unknown>>;
return users.map((u) => ({
id: u.Id as number,
name: (u.Title as string) || '',
email: (u.Email as string) || '',
loginName: (u.LoginName as string) || '',
}));
}
// Должность пользователя из профиля SharePoint (SPS-JobTitle). '' если недоступно.
public async getJobTitle(loginName: string): Promise<string> {
if (!loginName) return '';
try {
const props = (await getSP().profiles.getPropertiesFor(loginName)) as {
UserProfileProperties?: unknown;
};
const raw = props.UserProfileProperties;
const list: Array<{ Key: string; Value: string }> = Array.isArray(raw)
? (raw as Array<{ Key: string; Value: string }>)
: ((raw as { results?: Array<{ Key: string; Value: string }> })?.results ?? []);
const map: Record<string, string> = {};
for (const p of list) map[p.Key] = p.Value;
return map['SPS-JobTitle'] || map.Title || '';
} catch {
return '';
}
}
// Решение согласующего по строке: статус + дата + комментарий.
public async setDecision(rowId: number, status: string, comment: string): Promise<void> {
await this.list()
.items.getById(rowId)
.update({
[FIELDS.status]: status,
[FIELDS.date]: new Date().toISOString(),
[FIELDS.comment]: comment,
});
}
// Удаляет все строки согласования по заявке (при возврате/повторной отправке).
public async deleteForRequest(requestId: number): Promise<void> {
const rows = await this.getForRequest(requestId);
for (const row of rows) {
await this.list().items.getById(row.id).delete();
}
}
// Id текущего пользователя на сайте (для сравнения с полем «Согласующий»).
public async getCurrentUserId(): Promise<number> {
const user = await getSP().web.currentUser.select('Id')();
return user.Id;
}
// email → числовой Id пользователя сайта (для Person-поля «Согласующий»).
private async resolveUserId(email: string): Promise<number> {
const user = await this.ensure(email);
return user.Id;
}
// email → отображаемое имя (ФИО). Фолбэк на email, если имя недоступно.
public async resolveUserName(email: string): Promise<string> {
try {
const user = await this.ensure(email);
return user.Title || email;
} catch {
return email;
}
}
private async ensure(email: string): Promise<{ Id: number; Title: string }> {
const login = email.indexOf('|') >= 0 ? email : `i:0#.f|membership|${email}`;
return getSP().web.ensureUser(login);
}
private formatDate(iso: string): string {
if (!iso) return '';
const d = new Date(iso);
const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`;
const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`;
return `${dd}.${mm}.${d.getFullYear()}`;
}
}
@@ -0,0 +1,47 @@
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { APPROVAL_API_CLIENT_ID, APPROVAL_API_BASE_URL } from '../config';
import { IApiResponse, IRequestType, IResolvedRoute } from './models/IApprovalRouteApi';
// Все обращения к внешнему approval-route API — только здесь.
// Клиент авторизуется через AadHttpClient (SSO по текущему пользователю).
async function client(context: WebPartContext): Promise<AadHttpClient> {
return context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
}
// GET /api/request-types — список типов заявок (id + имя). Пригодится, чтобы
// сопоставить форму с RequestTypeId, если он неизвестен заранее.
export async function getRequestTypes(context: WebPartContext): Promise<IRequestType[]> {
const c = await client(context);
const response: HttpClientResponse = await c.get(
`${APPROVAL_API_BASE_URL}/api/request-types`,
AadHttpClient.configurations.v1
);
if (!response.ok) {
throw new Error(`request-types: ${response.status} ${response.statusText}`);
}
const body: IApiResponse<IRequestType[]> = await response.json();
return body.data ?? [];
}
// GET /api/template-routes/resolve — планируемый маршрут согласования для типа
// заявки и пользователя. Бэкенд сам подставляет согласующих (executorEmail/position)
// по ролям исходя из userEmail.
export async function resolveRoute(
context: WebPartContext,
requestTypeId: number,
userEmail: string
): Promise<IResolvedRoute> {
const c = await client(context);
const url =
`${APPROVAL_API_BASE_URL}/api/template-routes/resolve` +
`?RequestTypeId=${requestTypeId}` +
`&UserEmail=${encodeURIComponent(userEmail)}` +
`&Status=Approval`;
const response: HttpClientResponse = await c.get(url, AadHttpClient.configurations.v1);
if (!response.ok) {
throw new Error(`resolve: ${response.status} ${response.statusText}`);
}
const body: IApiResponse<IResolvedRoute> = await response.json();
return body.data;
}
@@ -0,0 +1,41 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { resolveRoute } from './approvalRoute.service';
import { ApprovalListService, IApproverToWrite } from './approvalList.service';
import { IMatrixRow } from './models/IMatrixRow';
// Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы.
// Имена согласующих подтягиваются по executorEmail (бэкенд отдаёт email + должность).
export async function getPlannedMatrix(
context: WebPartContext,
requestTypeId: number,
userEmail: string,
approval: ApprovalListService
): Promise<IMatrixRow[]> {
if (!userEmail) return [];
const route = await resolveRoute(context, requestTypeId, userEmail);
const ordered = route.steps.slice().sort((a, b) => a.orderNo - b.orderNo);
return Promise.all(
ordered.map(async (s) => ({
order: s.orderNo,
who: s.executorEmail ? await approval.resolveUserName(s.executorEmail) : '',
email: s.executorEmail || '',
position: s.position || '',
status: '—',
date: '',
comment: '',
allowReassign: s.allowReassignToOtherApprovers,
}))
);
}
// Записать строки согласующих для заявки с указанным статусом.
// Матрица перезаписывается целиком (для повторной отправки/черновика).
export async function writeApprovers(
approval: ApprovalListService,
requestId: number,
approvers: IApproverToWrite[],
status: string
): Promise<void> {
await approval.deleteForRequest(requestId);
await approval.createSteps(requestId, approvers, status);
}
@@ -0,0 +1,92 @@
import { getSP } from '../pnp/pnpjs';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
import { ApprovalListService } from './approvalList.service';
import { REQUEST_STATUS } from './models/statuses';
import { IApprovalRow } from './models/IApprovalRow';
import { ISiteUser } from './models/ISiteUser';
export type ApproverDecision = 'approve' | 'return' | 'reject';
// Решение согласующего → статус его строки в списке согласования.
const ROW_STATUS: Record<ApproverDecision, string> = {
approve: REQUEST_STATUS.approved,
return: REQUEST_STATUS.returned,
reject: REQUEST_STATUS.rejected,
};
// Действия согласующего: определение его строки, применение решения,
// делегирование и пересчёт статуса заявки в контентном списке.
//
// ВНИМАНИЕ: используется в режиме согласующего, который открывается по конкретной
// заявке (requestId). Пока в портале нет маршрутизации на форму заявки, этот сервис
// готов, но UI-режим согласующего ещё не подключён (см. план).
export class ApproverService {
constructor(
private readonly approval: ApprovalListService,
private readonly dataListTitle: string
) {}
// Ожидающая строка текущего пользователя, если сейчас его очередь; иначе undefined.
// Последовательное согласование: активна первая строка со статусом «На согласовании».
public async getPendingRowForCurrentUser(requestId: number): Promise<IApprovalRow | undefined> {
const rows = await this.approval.getForRequest(requestId);
const userId = await this.approval.getCurrentUserId();
const firstPending = rows.filter((r) => r.status === REQUEST_STATUS.onApproval)[0];
if (firstPending && firstPending.approverId === userId) return firstPending;
return undefined;
}
// Делегирование: согласующий добавляет делеганта ПЕРЕД собой (Order — между
// предыдущей строкой и своей). Делегат согласует раньше и сам не делегирует.
public async delegate(
requestId: number,
currentRowId: number,
user: ISiteUser
): Promise<void> {
const rows = await this.approval.getForRequest(requestId);
const current = rows.filter((r) => r.id === currentRowId)[0];
if (!current) return;
const before = rows.filter((r) => r.order < current.order);
const prevOrder = before.length > 0 ? before[before.length - 1].order : current.order - 1;
const newOrder = (prevOrder + current.order) / 2;
const position = await this.approval.getJobTitle(user.loginName);
await this.approval.addApproverRow(
requestId,
{ email: user.email, position, allowReassign: false },
newOrder,
REQUEST_STATUS.onApproval
);
}
// Применяет решение: обновляет строку и пересчитывает статус заявки.
// Возвращает новый статус заявки.
public async decide(
requestId: number,
rowId: number,
decision: ApproverDecision,
comment: string
): Promise<string> {
await this.approval.setDecision(rowId, ROW_STATUS[decision], comment);
const rows = await this.approval.getForRequest(requestId);
const requestStatus = this.computeRequestStatus(rows);
await this.updateRequestStatus(requestId, requestStatus);
return requestStatus;
}
private computeRequestStatus(rows: IApprovalRow[]): string {
if (rows.some((r) => r.status === REQUEST_STATUS.rejected)) return REQUEST_STATUS.rejected;
if (rows.some((r) => r.status === REQUEST_STATUS.returned)) return REQUEST_STATUS.returned;
if (rows.length > 0 && rows.every((r) => r.status === REQUEST_STATUS.approved)) {
return REQUEST_STATUS.approved;
}
return REQUEST_STATUS.onApproval;
}
private async updateRequestStatus(id: number, status: string): Promise<void> {
await getSP().web.lists.getByTitle(this.dataListTitle).items.getById(id).update({
Status: status,
});
}
}
@@ -0,0 +1,21 @@
// Единая точка импорта механизма согласования.
export { REQUEST_STATUS, REQUEST_STATUS_CHOICES } from './models/statuses';
export type { ISiteUser } from './models/ISiteUser';
export type { IMatrixRow } from './models/IMatrixRow';
export type { IApprovalRow } from './models/IApprovalRow';
export type {
IRequestType,
IResolvedRoute,
IResolvedRouteStep,
} from './models/IApprovalRouteApi';
export { getRequestTypes, resolveRoute } from './approvalRoute.service';
export { ApprovalListService } from './approvalList.service';
export type { IApproverToWrite } from './approvalList.service';
export { ApproverService } from './approverDecision.service';
export type { ApproverDecision } from './approverDecision.service';
export { getPlannedMatrix, writeApprovers } from './approvalSubmission.service';
export { useMatrixEditor } from './useMatrixEditor';
export type { IUseMatrixEditor } from './useMatrixEditor';
export { ApprovalMatrix } from './ui/ApprovalMatrix';
export { UserPicker } from './ui/UserPicker';
@@ -0,0 +1,32 @@
// Типы внешнего approval-route API (api.tnsu.kz).
export type RouteType = 'None' | 'Parallel' | 'Sequential';
export type ApprovalStatus = 'None' | 'Approval' | 'Execution';
// GET /api/request-types → { data: IRequestType[] }
export interface IRequestType {
id: number;
name: string;
pageUrl: string;
}
// Обёртка ответа API (все ответы приходят в поле data).
export interface IApiResponse<T> {
data: T;
}
// GET /api/template-routes/resolve → data
export interface IResolvedRoute {
routeId: number;
routeType: RouteType;
requiresControl: boolean;
steps: IResolvedRouteStep[];
}
export interface IResolvedRouteStep {
id: number;
orderNo: number;
executorEmail: string | null;
position: string | null;
allowReassignToOtherApprovers: boolean;
}
@@ -0,0 +1,8 @@
// Строка согласования из списка «Согласование — <форма>».
export interface IApprovalRow {
id: number;
status: string;
approverId: number; // Id пользователя сайта в поле «Согласующий»
order: number; // колонка Order (очередь согласования)
allowReassign: boolean; // AllowReassignToOtherApprovers
}
@@ -0,0 +1,11 @@
// Строка матрицы согласования для отображения и редактирования на форме.
export interface IMatrixRow {
order: number; // порядок согласования (колонка Order)
who: string; // ФИО согласующего
email: string; // email (для записи новых строк; '' для загруженных из списка)
position: string; // должность/роль
status: string; // статус согласования или «—» для планируемой
date: string; // дата решения (дд.мм.гггг) или ''
comment: string;
allowReassign: boolean; // может ли этот согласующий добавить делеганта
}
@@ -0,0 +1,7 @@
// Пользователь сайта (результат поиска для пикера согласующего).
export interface ISiteUser {
id: number;
name: string;
email: string;
loginName: string; // claims-логин (для чтения должности из профиля)
}
@@ -0,0 +1,25 @@
// Значения Choice-колонки «Статус» — общий жизненный цикл заявки портала.
// Одинаков для контентного списка (заявка) и списка согласования (строки маршрута).
// В слое формы кодом ставятся Draft (черновик) и OnApproval (отправка на согласование);
// Approved/Rejected/Returned выставляются при решениях согласующих; Execution/Closed —
// зарезервированы под будущие этапы.
export const REQUEST_STATUS = {
draft: 'Черновик',
onApproval: 'На согласовании',
approved: 'Согласовано',
rejected: 'Отклонено',
returned: 'Возвращена на доработку',
execution: 'Выполнение',
closed: 'Закрыта',
} as const;
// Полный набор значений для Choice-колонки при провижининге.
export const REQUEST_STATUS_CHOICES: string[] = [
REQUEST_STATUS.draft,
REQUEST_STATUS.onApproval,
REQUEST_STATUS.approved,
REQUEST_STATUS.rejected,
REQUEST_STATUS.returned,
REQUEST_STATUS.execution,
REQUEST_STATUS.closed,
];
@@ -0,0 +1,89 @@
import * as React from 'react';
import styles from './approval.module.scss';
import { Icon } from '../../ui';
import { IUseMatrixEditor } from '../useMatrixEditor';
import { ISiteUser } from '../models/ISiteUser';
import { UserPicker } from './UserPicker';
export interface IApprovalMatrixProps {
editor: IUseMatrixEditor;
search: (query: string) => Promise<ISiteUser[]>;
}
/** Таблица маршрута согласования + (если editable) правка порядка/состава. */
export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({ editor, search }) => {
const { rows, loading, error, editable } = editor;
return (
<div className={styles.matrixWrap}>
<div className={styles.matrixTitle}>Маршрут согласования</div>
{loading && <div className={styles.matrixNote}>Загрузка маршрута…</div>}
{!loading && error && <div className={styles.matrixNote}>Маршрут недоступен: {error}</div>}
{!loading && !error && rows.length === 0 && (
<div className={styles.matrixNote}>Согласующие не заданы.</div>
)}
{!loading && !error && rows.length > 0 && (
<table className={styles.matrix}>
<thead>
<tr>
<th>Согласующий</th>
<th>Должность</th>
<th>Статус</th>
<th>Дата</th>
{editable && <th />}
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>
<td>{r.who}</td>
<td>{r.position}</td>
<td>{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>
</td>
)}
</tr>
))}
</tbody>
</table>
)}
{editable && !loading && (
<div className={styles.matrixPicker}>
<UserPicker
search={search}
onPick={editor.addApprover}
placeholder="Добавить согласующего: начните вводить ФИО…"
/>
</div>
)}
</div>
);
};
@@ -0,0 +1,67 @@
import * as React from 'react';
import styles from './approval.module.scss';
import { ISiteUser } from '../models/ISiteUser';
export interface IUserPickerProps {
search: (query: string) => Promise<ISiteUser[]>;
onPick: (user: ISiteUser) => void;
placeholder?: string;
}
/** Лёгкий поиск пользователя сайта: ввод → выпадающий список → выбор. */
export const UserPicker: React.FC<IUserPickerProps> = ({ search, onPick, placeholder }) => {
const [query, setQuery] = React.useState<string>('');
const [results, setResults] = React.useState<ISiteUser[]>([]);
const [busy, setBusy] = React.useState<boolean>(false);
const handleChange = (value: string): void => {
setQuery(value);
if (value.trim().length < 2) {
setResults([]);
return;
}
setBusy(true);
search(value)
.then((r) => {
setResults(r);
setBusy(false);
})
.catch(() => {
setResults([]);
setBusy(false);
});
};
const handlePick = (user: ISiteUser): void => {
onPick(user);
setQuery('');
setResults([]);
};
return (
<div className={styles.picker}>
<input
className={styles.input}
value={query}
placeholder={placeholder || 'Начните вводить ФИО…'}
onChange={(e) => handleChange(e.target.value)}
/>
{busy && <div className={styles.pickerNote}>Поиск…</div>}
{!busy && results.length > 0 && (
<div className={styles.pickerList}>
{results.map((u) => (
<button
key={u.id}
type="button"
className={styles.pickerItem}
onClick={() => handlePick(u)}
>
{u.name}
{u.email && <span className={styles.pickerEmail}>{u.email}</span>}
</button>
))}
</div>
)}
</div>
);
};
@@ -0,0 +1,158 @@
@import '../../../../../styles/design-tokens';
// ---- Общий инпут (пикер) ----
.input {
width: 100%;
padding: 11px 13px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
font-family: $font-sans;
font-size: 13.5px;
color: $gray-900;
transition: .18s;
@include input-focus-gold;
&::placeholder {
color: $gray-400;
}
}
// ---- Пикер пользователя ----
.picker {
position: relative;
}
.pickerNote {
margin-top: $sp-1;
font-size: $fs-caption;
color: $gray-500;
}
.pickerList {
position: absolute;
z-index: 20;
left: 0;
right: 0;
margin-top: $sp-1;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-sm;
box-shadow: $sh-2;
overflow: hidden;
}
.pickerItem {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 9px 12px;
border: 0;
background: transparent;
text-align: left;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
cursor: pointer;
&:hover {
background: $navy-50;
}
}
.pickerEmail {
font-size: $fs-caption;
color: $gray-500;
}
// ---- Матрица согласования ----
.matrixWrap {
margin-top: $sp-5;
padding: $sp-4;
border: 1px solid $gray-200;
border-radius: $r-md;
background: $gray-50;
}
.matrixTitle {
font-family: $font-serif;
font-size: $fs-section-title;
font-weight: 600;
color: $navy-800;
margin-bottom: $sp-3;
}
.matrixNote {
font-size: $fs-sm;
color: $gray-500;
}
.matrix {
width: 100%;
border-collapse: collapse;
font-size: $fs-sm;
th {
text-align: left;
font-weight: 500;
color: $gray-500;
font-size: $fs-label;
padding: 6px 8px;
border-bottom: 1px solid $gray-200;
}
td {
padding: 8px;
border-bottom: 1px solid $gray-200;
color: $gray-900;
vertical-align: middle;
}
}
.matrixActions {
display: flex;
gap: $sp-1;
justify-content: flex-end;
}
.iconBtn {
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:not(:disabled) {
border-color: $gold-400;
color: $gold-600;
}
&:disabled {
opacity: .4;
cursor: default;
}
}
.iconBtnDanger {
&:hover:not(:disabled) {
border-color: $red-500;
color: $red-500;
}
}
.flipY {
transform: scaleY(-1);
}
.matrixPicker {
margin-top: $sp-3;
}
@@ -0,0 +1,94 @@
import { useState, useEffect } from 'react';
import { IMatrixRow } from './models/IMatrixRow';
import { ISiteUser } from './models/ISiteUser';
export interface IUseMatrixEditor {
rows: IMatrixRow[];
loading: boolean;
error: string | undefined;
editable: boolean;
addApprover: (user: ISiteUser) => void;
moveUp: (index: number) => void;
moveDown: (index: number) => void;
remove: (index: number) => void;
}
function swap(rows: IMatrixRow[], a: number, b: number): IMatrixRow[] {
const next = [...rows];
[next[a], next[b]] = [next[b], next[a]];
return next;
}
/**
* Загружает матрицу согласования и (если editable) позволяет её редактировать.
* @param load загрузчик строк (планируемый маршрут из resolve или строки заявки).
* @param reloadKey при изменении — перезагрузка (например, email инициатора).
* @param editable доступны ли правки (порядок/добавление/удаление).
* @param ready можно ли грузить (false — ждём готовности данных).
* @param getJobTitle должность добавляемого согласующего из профиля.
*/
export function useMatrixEditor(
load: () => Promise<IMatrixRow[]>,
reloadKey: string,
editable: boolean,
ready: boolean,
getJobTitle: (loginName: string) => Promise<string>
): IUseMatrixEditor {
const [rows, setRows] = useState<IMatrixRow[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | undefined>(undefined);
useEffect(() => {
if (!ready) return;
let active = true;
setLoading(true);
load()
.then((r) => {
if (!active) return;
setRows(r);
setError(undefined);
setLoading(false);
})
.catch((err: Error) => {
if (!active) return;
setError(err.message);
setLoading(false);
});
return () => {
active = false;
};
// load — стабильная ссылка от вызывающего (useCallback); перезагрузка по reloadKey/ready.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reloadKey, ready]);
const appendUser = (user: ISiteUser, position: string): void => {
setRows((prev) => {
if (user.email && prev.some((r) => r.email === user.email)) return prev;
const row: IMatrixRow = {
order: prev.length + 1,
who: user.name,
email: user.email,
position,
status: '—',
date: '',
comment: '',
allowReassign: false,
};
return [...prev, row];
});
};
const addApprover = (user: ISiteUser): void => {
// Должность подтягиваем из профиля; при неудаче добавляем без неё.
getJobTitle(user.loginName)
.then((position) => appendUser(user, position))
.catch(() => appendUser(user, ''));
};
const moveUp = (i: number): void => setRows((prev) => (i <= 0 ? prev : swap(prev, i, i - 1)));
const moveDown = (i: number): void =>
setRows((prev) => (i >= prev.length - 1 ? prev : swap(prev, i, i + 1)));
const remove = (i: number): void => setRows((prev) => prev.filter((_, idx) => idx !== i));
return { rows, loading, error, editable, addApprover, moveUp, moveDown, remove };
}