Merge remote-tracking branch 'origin/main'
Tests / frontend-test (push) Successful in 1m10s

This commit is contained in:
gorbenko.pavel
2026-08-17 13:20:05 +05:00
8 changed files with 91 additions and 156 deletions
+4 -4
View File
@@ -17,7 +17,7 @@
"@microsoft/sp-webpart-base": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1",
"@pnp/sp": "^4.20.0", "@pnp/sp": "^4.20.0",
"@pnp/spfx-controls-react": "^3.25.0", "@pnp/spfx-controls-react": "^3.25.0",
"@tnsu/ui-kit": "^0.3.0", "@tnsu/ui-kit": "^0.4.0",
"react": "17.0.1", "react": "17.0.1",
"react-dom": "17.0.1", "react-dom": "17.0.1",
"tslib": "2.3.1" "tslib": "2.3.1"
@@ -9870,9 +9870,9 @@
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/@tnsu/ui-kit": { "node_modules/@tnsu/ui-kit": {
"version": "0.3.0", "version": "0.4.0",
"resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.3.0/ui-kit-0.3.0.tgz", "resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.4.0/ui-kit-0.4.0.tgz",
"integrity": "sha512-V9pTS/rBmU9KInczI8jJ/KHT96hfX0OlnjIJ2NZ/iEQTOE93I9xMfmgpj83PSRrI6/xzyybu+8k/qVO6LP926w==", "integrity": "sha512-J3cBvoR2vV0SC5GihOagLAqvC0txQE342qIh6viabMt+Um6Jg4mhTff+ZkVahG0g+bF6sqSCUl3x813aO82KuQ==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"@fluentui/react": "^8.106.4", "@fluentui/react": "^8.106.4",
+1 -1
View File
@@ -21,7 +21,7 @@
"@microsoft/sp-webpart-base": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1",
"@pnp/sp": "^4.20.0", "@pnp/sp": "^4.20.0",
"@pnp/spfx-controls-react": "^3.25.0", "@pnp/spfx-controls-react": "^3.25.0",
"@tnsu/ui-kit": "^0.3.0", "@tnsu/ui-kit": "^0.4.0",
"react": "17.0.1", "react": "17.0.1",
"react-dom": "17.0.1", "react-dom": "17.0.1",
"tslib": "2.3.1" "tslib": "2.3.1"
@@ -66,9 +66,8 @@
margin-left: 0; margin-left: 0;
} }
// Цвет пунктов списка — те же цвета, что у StatusBadge (см. statusOptionClassName // Цвет пунктов списка — те же цвета, что у StatusBadge. Только color:
// в RequestsTableView.tsx). Только color: background-color нативный <option> // background-color нативный <option> показывает не во всех браузерах одинаково.
// показывает не во всех браузерах одинаково, поэтому не используем.
.optionDraft { color: $gray-700; } .optionDraft { color: $gray-700; }
.optionOnApproval { color: $blue-600; } .optionOnApproval { color: $blue-600; }
.optionApproved { color: $green-600; } .optionApproved { color: $green-600; }
@@ -112,6 +111,8 @@
background: $red-50; background: $red-50;
} }
// Карточка-контейнер вокруг таблицы. Сама таблица (заголовки, строки, ховер,
// горизонтальный скролл) теперь в @tnsu/ui-kit DataTable.
.tableWrap { .tableWrap {
background: $white; background: $white;
border: 1px solid $gray-200; border: 1px solid $gray-200;
@@ -120,97 +121,7 @@
overflow: hidden; overflow: hidden;
} }
.table { // Пустой комментарий в колонке «Причина возврата/отклонения».
width: 100%;
border-collapse: collapse;
font-size: $fs-sm;
th {
text-align: left;
padding: 12px 16px;
background: $gray-50;
border-bottom: 1px solid $gray-200;
color: $gray-500;
font-size: $fs-label;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
white-space: nowrap;
}
td {
padding: 14px 16px;
border-bottom: 1px solid $gray-100;
color: $gray-900;
vertical-align: middle;
overflow-wrap: break-word;
word-break: break-word;
}
tbody tr:last-child td {
border-bottom: 0;
}
}
// Кликабельна вся строка (см. RequestsTableView — onClick на <tr>).
.row {
cursor: pointer;
transition: background .16s;
&:hover {
background: $gold-50;
}
&:focus-visible {
outline: 2px solid $gold-500;
outline-offset: -2px;
}
}
.colId {
width: 70px;
color: $gray-400;
white-space: nowrap;
}
.colType {
width: 200px;
color: $navy-700;
font-weight: 500;
}
.colTitle {
min-width: 240px;
}
.colDate {
width: 110px;
color: $gray-500;
white-space: nowrap;
}
.colAuthor {
width: 280px;
color: $gray-500;
}
.colStatus {
width: 190px;
}
// Причина возврата — свободный текст согласующего, поэтому переносим по словам
// (в т.ч. длинное слово без пробелов, иначе оно растянет таблицу).
.colComment {
white-space: normal;
overflow-wrap: anywhere;
color: $gray-700;
}
.commentEmpty { .commentEmpty {
color: $gray-400; color: $gray-400;
} }
// Раскраска статуса — теперь только в shared/approval/ui/StatusBadge (см.
// использование ниже с size="sm"). Раньше здесь была своя копия этих цветов —
// она разошлась с эталоном (На согласовании/Возвращена на доработку оказались
// перепутаны), поэтому копию убрали, а не подправили.
@@ -13,6 +13,7 @@ import {
searchOwnRequests, searchOwnRequests,
} from './myRequests.service'; } from './myRequests.service';
import { IMyRequest } from './models/IMyRequest'; import { IMyRequest } from './models/IMyRequest';
import { DataTable, IDataTableColumn } from '@tnsu/ui-kit';
import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType, StatusBadge } from '../../shared/approval'; import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType, StatusBadge } from '../../shared/approval';
import { onRequestsChanged } from '../../shared/requestEvents'; import { onRequestsChanged } from '../../shared/requestEvents';
import { useRoutedQuery } from '../../shared/usePersistedQuery'; import { useRoutedQuery } from '../../shared/usePersistedQuery';
@@ -201,61 +202,50 @@ const Table: React.FC<{
statusFilter: string; statusFilter: string;
onOpen: (request: IMyRequest) => void; onOpen: (request: IMyRequest) => void;
}> = ({ rows, view, statusFilter, onOpen }) => { }> = ({ rows, view, statusFilter, onOpen }) => {
const columns = columnsFor(view, statusFilter); const cols = columnsFor(view, statusFilter);
// Колонки для DataTable строим динамически: базовые всегда, автор/статус/
// комментарий — по columnsFor (зависит от раздела и фильтра). Кастомные ячейки
// (статус → StatusBadge, дата → формат) задаются через render.
const columns: IDataTableColumn<IMyRequest>[] = [
{ key: 'id', title: 'ID', width: '70px' },
{ key: 'type', title: 'Тип', width: '200px', render: (r) => r.requestTypeName },
{ key: 'title', title: 'Название', render: (r) => r.title || `#${r.id}` },
{ key: 'date', title: 'Дата', width: '110px', render: (r) => formatDate(r.created) }
];
if (cols.author) {
columns.push({
key: 'author',
title: 'Автор',
width: '280px',
render: (r) => [r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'
});
}
if (cols.status) {
columns.push({
key: 'status',
title: 'Статус',
width: '190px',
render: (r) => (r.status ? <StatusBadge status={r.status} size="sm" /> : '—')
});
}
if (cols.commentLabel) {
columns.push({
key: 'comment',
title: cols.commentLabel,
render: (r) => r.decisionComment || <span className={styles.commentEmpty}>Без комментария</span>
});
}
return ( return (
<div className={styles.tableWrap}> <div className={styles.tableWrap}>
<table className={styles.table}> <DataTable
<thead> columns={columns}
<tr> rows={rows}
<th className={styles.colId}>ID</th> rowKey={(r) => `${r.requestTypeId}-${r.id}`}
<th className={styles.colType}>Тип</th> onRowClick={onOpen}
<th className={styles.colTitle}>Название</th> />
<th className={styles.colDate}>Дата</th> </div>
{columns.author && <th className={styles.colAuthor}>Автор</th>}
{columns.status && <th className={styles.colStatus}>Статус</th>}
{columns.commentLabel && <th className={styles.colComment}>{columns.commentLabel}</th>}
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr
className={styles.row}
key={`${r.requestTypeId}-${r.id}`}
tabIndex={0}
role="button"
aria-label={`Открыть заявку «${r.title || r.id}»`}
onClick={() => onOpen(r)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpen(r);
}
}}
>
<td className={styles.colId}>{r.id}</td>
<td className={styles.colType}>{r.requestTypeName}</td>
<td className={styles.colTitle}>{r.title || `#${r.id}`}</td>
<td className={styles.colDate}>{formatDate(r.created)}</td>
{columns.author && (
<td className={styles.colAuthor}>
{[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'}
</td>
)}
{columns.status && (
<td className={styles.colStatus}>
{r.status ? <StatusBadge status={r.status} size="sm" /> : '—'}
</td>
)}
{columns.commentLabel && (
<td className={styles.colComment}>
{r.decisionComment || <span className={styles.commentEmpty}>Без комментария</span>}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
); );
}; };
@@ -278,13 +278,15 @@ export class ApprovalListService {
FIELDS.slaHours, FIELDS.slaHours,
FIELDS.askOverdueReason, FIELDS.askOverdueReason,
`${FIELDS.approver}/Title`, `${FIELDS.approver}/Title`,
`${FIELDS.approver}/EMail` `${FIELDS.approver}/EMail`,
`${FIELDS.delegatedBy}/Title`
) )
.expand(FIELDS.approver) .expand(FIELDS.approver, FIELDS.delegatedBy)
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>; .orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
return rows.map((r, i) => { return rows.map((r, i) => {
const approver = r[FIELDS.approver] as { Title?: string; EMail?: string } | undefined; const approver = r[FIELDS.approver] as { Title?: string; EMail?: string } | undefined;
const delegatedBy = r[FIELDS.delegatedBy] as { Title?: string } | undefined;
return { return {
order: (r[FIELDS.order] as number) ?? i + 1, order: (r[FIELDS.order] as number) ?? i + 1,
who: approver?.Title || '', who: approver?.Title || '',
@@ -294,6 +296,9 @@ 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,
// Пусто у строк из штатного маршрута; заполнено только у делегатов
// (кого согласующий добавил перед собой) — матрица пометит их.
addedBy: delegatedBy?.Title || '',
phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval, phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval,
// Читаем, чтобы не потерять при пересохранении: writeRoute удаляет // Читаем, чтобы не потерять при пересохранении: writeRoute удаляет
// строки и создаёт их заново из матрицы, и без этих полей SLA, // строки и создаёт их заново из матрицы, и без этих полей SLA,
@@ -8,6 +8,10 @@ export interface IMatrixRow {
date: string; // дата решения (дд.мм.гггг) или '' date: string; // дата решения (дд.мм.гггг) или ''
comment: string; comment: string;
allowReassign: boolean; // может ли этот согласующий добавить делеганта allowReassign: boolean; // может ли этот согласующий добавить делеганта
// ФИО согласующего, который добавил ЭТУ строку перед собой (доп. согласующий,
// добавленный другим согласующим с правом переназначения — DelegatedBy). ''
// для обычных строк маршрута. Матрица показывает это пометкой под именем.
addedBy?: string;
phase?: string; // фаза строки: «Согласование» | «Исполнение» (см. APPROVAL_PHASE) phase?: string; // фаза строки: «Согласование» | «Исполнение» (см. APPROVAL_PHASE)
// Шаг идёт ОДНОВРЕМЕННО с предыдущим (у обоих одинаковый order — так // Шаг идёт ОДНОВРЕМЕННО с предыдущим (у обоих одинаковый order — так
// activateNextQueued активирует их вместе). У первой строки всегда false. // activateNextQueued активирует их вместе). У первой строки всегда false.
@@ -141,6 +141,16 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
)} )}
<td data-label={roleLabel}> <td data-label={roleLabel}>
{r.who} {r.who}
{/* Доп. согласующий, добавленный другим согласующим (с правом
переназначения) — помечаем, кто его добавил. Пусто у обычных
строк маршрута. Как и комментарий — inline под именем, чтобы
не ломать rowSpan колонки «Шаг» у параллельных шагов. */}
{r.addedBy && (
<span className={styles.addedByInline}>
<span className={styles.addedByLabel}>Доп. согласующий, добавил:</span>{' '}
{r.addedBy}
</span>
)}
{/* Комментарий решения — мелким текстом под именем (не отдельной {/* Комментарий решения — мелким текстом под именем (не отдельной
строкой: иначе ломается rowSpan колонки «Шаг» у параллельных). */} строкой: иначе ломается rowSpan колонки «Шаг» у параллельных). */}
{r.comment && ( {r.comment && (
@@ -142,6 +142,20 @@
font-weight: 500; font-weight: 500;
} }
// Пометка «доп. согласующий, добавил: …» — под именем согласующего. Золотой
// акцент, чтобы отличать таких участников от штатных строк маршрута.
.addedByInline {
display: block;
margin-top: 3px;
font-size: $fs-caption;
color: $gold-600;
line-height: 1.45;
}
.addedByLabel {
font-weight: 500;
}
// ВАЖНО: не display:flex — это сняло бы с ячейки display:table-cell, и она // ВАЖНО: не display:flex — это сняло бы с ячейки display:table-cell, и она
// перестала бы вставать в строку рядом с соседней (кнопки уезжали на вторую // перестала бы вставать в строку рядом с соседней (кнопки уезжали на вторую
// строку). Ячейка остаётся табличной, кнопки держатся в строке через nowrap. // строку). Ячейка остаётся табличной, кнопки держатся в строке через nowrap.
@@ -287,7 +301,8 @@
} }
// Комментарий под именем — на всю ширину ячейки (своя строка). // Комментарий под именем — на всю ширину ячейки (своя строка).
.commentInline { .commentInline,
.addedByInline {
flex-basis: 100%; flex-basis: 100%;
} }