Compare commits
2 Commits
bfe0b95f26
...
d749bfae26
| Author | SHA1 | Date | |
|---|---|---|---|
| d749bfae26 | |||
| fa27e8d918 |
@@ -0,0 +1,82 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Ниже этой ширины контролы встают в колонку во всю ширину — в строку они
|
||||||
|
// на телефоне не помещаются даже с переносом.
|
||||||
|
$bar-stack-breakpoint: 640px;
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-3;
|
||||||
|
margin-bottom: $sp-4;
|
||||||
|
|
||||||
|
@media (max-width: $bar-stack-breakpoint) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Общий вид полей — как у поиска в шапке таблицы (см. RequestsTableView.module.scss).
|
||||||
|
@mixin field {
|
||||||
|
padding: 9px 13px;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background-color: $white;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-900;
|
||||||
|
transition: .18s;
|
||||||
|
|
||||||
|
@include input-focus-gold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
@include field;
|
||||||
|
|
||||||
|
// appearance: none + своя SVG-галочка — как в shared/ui/Select. Нативная
|
||||||
|
// стрелка ОС рисуется своим шрифтом и рядом с остальными полями портала
|
||||||
|
// выглядит чужеродно (и по-разному в разных браузерах).
|
||||||
|
appearance: none;
|
||||||
|
padding-right: 34px;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%236B7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 12px center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
|
||||||
|
@media (max-width: $bar-stack-breakpoint) {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.periodLabel {
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
@include field;
|
||||||
|
// Нативный date-инпут на iOS без явного размера схлопывается в ноль.
|
||||||
|
min-width: 140px;
|
||||||
|
|
||||||
|
@media (max-width: $bar-stack-breakpoint) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Цвета пунктов списка статусов — те же, что у StatusBadge. Только color:
|
||||||
|
// background-color нативный <option> показывает не во всех браузерах одинаково.
|
||||||
|
.optionDraft { color: $gray-700; }
|
||||||
|
.optionOnApproval { color: $blue-600; }
|
||||||
|
.optionApproved { color: $green-600; }
|
||||||
|
.optionRejected { color: $red-500; }
|
||||||
|
.optionReturned { color: $amber-700; }
|
||||||
|
.optionExecution { color: $navy-800; }
|
||||||
|
.optionClosed { color: $gray-500; }
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Button } from '@tnsu/ui-kit';
|
||||||
|
import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType } from '../../shared/approval';
|
||||||
|
import { ITableFilters } from './requestsTableFilters';
|
||||||
|
import styles from './RequestsFilterBar.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
filters: ITableFilters;
|
||||||
|
onChange: (filters: ITableFilters) => void;
|
||||||
|
/** Типы для фильтра «Тип»; undefined — фильтра нет (раздел уже про один тип). */
|
||||||
|
types: IRequestType[] | undefined;
|
||||||
|
/** Показывать фильтр «Статус» — только там, где статус у заявок разный. */
|
||||||
|
showStatus: boolean;
|
||||||
|
/** Есть что сбрасывать (фильтр или сортировка не по умолчанию). */
|
||||||
|
canReset: boolean;
|
||||||
|
onReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Цвет пункта в выпадающем списке — то же сопоставление статус→цвет, что в
|
||||||
|
// StatusBadge (VARIANT), только цвет текста, а не бейдж целиком: внутри
|
||||||
|
// нативного <option> нельзя отрендерить компонент, а background-color на нём
|
||||||
|
// браузеры показывают ненадёжно (в отличие от color). Если сопоставление там
|
||||||
|
// поменяется — поправить и здесь.
|
||||||
|
function statusOptionClassName(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case REQUEST_STATUS.onApproval:
|
||||||
|
case REQUEST_STATUS.onExecution:
|
||||||
|
return styles.optionOnApproval;
|
||||||
|
case REQUEST_STATUS.approved:
|
||||||
|
case REQUEST_STATUS.executed:
|
||||||
|
return styles.optionApproved;
|
||||||
|
case REQUEST_STATUS.rejected:
|
||||||
|
return styles.optionRejected;
|
||||||
|
case REQUEST_STATUS.returned:
|
||||||
|
return styles.optionReturned;
|
||||||
|
case REQUEST_STATUS.execution:
|
||||||
|
return styles.optionExecution;
|
||||||
|
case REQUEST_STATUS.closed:
|
||||||
|
return styles.optionClosed;
|
||||||
|
default:
|
||||||
|
return styles.optionDraft;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строка фильтров над таблицей заявок: тип, статус, период по дате создания.
|
||||||
|
// Сортировка — в заголовках колонок (SortableHeader), здесь только отбор.
|
||||||
|
export const RequestsFilterBar: React.FC<Props> = ({ filters, onChange, types, showStatus, canReset, onReset }) => {
|
||||||
|
const sortedTypes = types ? [...types].sort((a, b) => a.name.localeCompare(b.name, 'ru')) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.bar}>
|
||||||
|
{sortedTypes && (
|
||||||
|
<select
|
||||||
|
className={styles.select}
|
||||||
|
value={filters.typeId === undefined ? '' : String(filters.typeId)}
|
||||||
|
onChange={(e) => onChange({ ...filters, typeId: e.target.value ? Number(e.target.value) : undefined })}
|
||||||
|
aria-label="Фильтр по типу заявки"
|
||||||
|
>
|
||||||
|
<option value="">Все типы</option>
|
||||||
|
{sortedTypes.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showStatus && (
|
||||||
|
<select
|
||||||
|
// Цвет самого <select> (закрытое состояние) — тем же классом, что и у
|
||||||
|
// выбранного <option>.
|
||||||
|
className={`${styles.select} ${filters.status ? statusOptionClassName(filters.status) : ''}`}
|
||||||
|
value={filters.status}
|
||||||
|
onChange={(e) => onChange({ ...filters, status: e.target.value })}
|
||||||
|
aria-label="Фильтр по статусу"
|
||||||
|
>
|
||||||
|
<option value="">Все статусы</option>
|
||||||
|
{REQUEST_STATUS_CHOICES.map((s) => (
|
||||||
|
<option key={s} value={s} className={statusOptionClassName(s)}>
|
||||||
|
{s}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.period}>
|
||||||
|
<span className={styles.periodLabel}>Дата с</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className={styles.date}
|
||||||
|
value={filters.dateFrom}
|
||||||
|
// Границы связаны: «с» не позже «по» — календарь сам не даст выбрать иначе.
|
||||||
|
max={filters.dateTo || undefined}
|
||||||
|
onChange={(e) => onChange({ ...filters, dateFrom: e.target.value })}
|
||||||
|
aria-label="Дата создания с"
|
||||||
|
/>
|
||||||
|
<span className={styles.periodLabel}>по</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className={styles.date}
|
||||||
|
value={filters.dateTo}
|
||||||
|
min={filters.dateFrom || undefined}
|
||||||
|
onChange={(e) => onChange({ ...filters, dateTo: e.target.value })}
|
||||||
|
aria-label="Дата создания по"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canReset && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={onReset}>
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -34,48 +34,6 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Фильтр по статусу — только на вкладке «Инициированные». Стоит слева от поиска
|
|
||||||
// и прижат к нему: вместе это один блок «чем сузить список».
|
|
||||||
.statusFilter {
|
|
||||||
// appearance: none + своя SVG-галочка — как в shared/ui/Select. Нативная
|
|
||||||
// стрелка ОС рисуется своим шрифтом и рядом с остальными полями портала
|
|
||||||
// выглядит чужеродно (и по-разному в разных браузерах).
|
|
||||||
appearance: none;
|
|
||||||
margin-left: auto;
|
|
||||||
// Справа место под галочку; по вертикали — как у .search, чтобы оба контрола
|
|
||||||
// в шапке были одной высоты.
|
|
||||||
padding: 9px 34px 9px 13px;
|
|
||||||
border: 1px solid $gray-300;
|
|
||||||
border-radius: $r-sm;
|
|
||||||
background-color: $white;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%236B7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: right 12px center;
|
|
||||||
font-family: $font-sans;
|
|
||||||
font-size: $fs-sm;
|
|
||||||
color: $gray-900;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: .18s;
|
|
||||||
|
|
||||||
@include input-focus-gold;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Когда фильтр есть, отступ до края уже задан им (margin-left: auto) — иначе
|
|
||||||
// поиск сам отжимается вправо.
|
|
||||||
.statusFilter + .search {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Цвет пунктов списка — те же цвета, что у StatusBadge. Только color:
|
|
||||||
// background-color нативный <option> показывает не во всех браузерах одинаково.
|
|
||||||
.optionDraft { color: $gray-700; }
|
|
||||||
.optionOnApproval { color: $blue-600; }
|
|
||||||
.optionApproved { color: $green-600; }
|
|
||||||
.optionRejected { color: $red-500; }
|
|
||||||
.optionReturned { color: $amber-700; }
|
|
||||||
.optionExecution { color: $navy-800; }
|
|
||||||
.optionClosed { color: $gray-500; }
|
|
||||||
|
|
||||||
.search {
|
.search {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import {
|
|||||||
ApprovalQueueKind,
|
ApprovalQueueKind,
|
||||||
filterPendingByQuery,
|
filterPendingByQuery,
|
||||||
getAllRequestsForType,
|
getAllRequestsForType,
|
||||||
|
getAllOwnRequests,
|
||||||
|
getAllRequestsRowsForType,
|
||||||
getApprovalQueue,
|
getApprovalQueue,
|
||||||
getApprovalQueueIndex,
|
getApprovalQueueIndex,
|
||||||
|
getApprovalQueueRows,
|
||||||
getLinkedTypes,
|
getLinkedTypes,
|
||||||
getOwnRequests,
|
getOwnRequests,
|
||||||
getOwnRequestsByStatus,
|
getOwnRequestsByStatus,
|
||||||
@@ -15,7 +18,7 @@ import {
|
|||||||
} from './myRequests.service';
|
} from './myRequests.service';
|
||||||
import { IMyRequest } from './models/IMyRequest';
|
import { IMyRequest } from './models/IMyRequest';
|
||||||
import { DataTable, IDataTableColumn, IconButton } from '@tnsu/ui-kit';
|
import { DataTable, IDataTableColumn, IconButton } from '@tnsu/ui-kit';
|
||||||
import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType, StatusBadge } from '../../shared/approval';
|
import { REQUEST_STATUS, IRequestType, StatusBadge } from '../../shared/approval';
|
||||||
import { confirmAndDeleteDraft, isDeletableStatus } from '../../shared/draftDeletion';
|
import { confirmAndDeleteDraft, isDeletableStatus } from '../../shared/draftDeletion';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
import { getFormComponent } from '../../forms/registry';
|
import { getFormComponent } from '../../forms/registry';
|
||||||
@@ -24,6 +27,10 @@ import { perfScope } from '../../shared/perfTrace';
|
|||||||
import { useRoutedQuery } from '../../shared/usePersistedQuery';
|
import { useRoutedQuery } from '../../shared/usePersistedQuery';
|
||||||
import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
|
import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||||||
import { Pager } from './Pager';
|
import { Pager } from './Pager';
|
||||||
|
import { RequestsFilterBar } from './RequestsFilterBar';
|
||||||
|
import { SortableHeader } from './SortableHeader';
|
||||||
|
import { filterRows, hasActiveFilters, ISortState, SortKey, sortRows } from './requestsTableFilters';
|
||||||
|
import { useTableRefinement } from './useTableRefinement';
|
||||||
import styles from './RequestsTableView.module.scss';
|
import styles from './RequestsTableView.module.scss';
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
@@ -90,31 +97,7 @@ const EMPTY_TEXTS: Record<ListView, string> = {
|
|||||||
all: 'Заявок этого типа пока нет.',
|
all: 'Заявок этого типа пока нет.',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Цвет пункта в выпадающем списке фильтра — то же сопоставление статус→цвет,
|
const NO_MATCHES_TEXT = 'Нет заявок, подходящих под выбранные фильтры.';
|
||||||
// что в StatusBadge (VARIANT), только цвет текста, а не бейдж целиком: внутри
|
|
||||||
// нативного <option> нельзя отрендерить компонент, а background-color на нём
|
|
||||||
// браузеры показывают ненадёжно (в отличие от color). Если сопоставление там
|
|
||||||
// поменяется — поправить и здесь, второй раз найти дублирование негде.
|
|
||||||
function statusOptionClassName(status: string): string {
|
|
||||||
switch (status) {
|
|
||||||
case REQUEST_STATUS.onApproval:
|
|
||||||
case REQUEST_STATUS.onExecution:
|
|
||||||
return styles.optionOnApproval;
|
|
||||||
case REQUEST_STATUS.approved:
|
|
||||||
case REQUEST_STATUS.executed:
|
|
||||||
return styles.optionApproved;
|
|
||||||
case REQUEST_STATUS.rejected:
|
|
||||||
return styles.optionRejected;
|
|
||||||
case REQUEST_STATUS.returned:
|
|
||||||
return styles.optionReturned;
|
|
||||||
case REQUEST_STATUS.execution:
|
|
||||||
return styles.optionExecution;
|
|
||||||
case REQUEST_STATUS.closed:
|
|
||||||
return styles.optionClosed;
|
|
||||||
default:
|
|
||||||
return styles.optionDraft;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Колонки: «Автор» нужен там, где заявка чужая; «Статус» — где он не следует из
|
// Колонки: «Автор» нужен там, где заявка чужая; «Статус» — где он не следует из
|
||||||
// самого раздела. В истории решений важно и то, и другое: заявка чужая, а её
|
// самого раздела. В истории решений важно и то, и другое: заявка чужая, а её
|
||||||
@@ -224,11 +207,16 @@ const Table: React.FC<{
|
|||||||
rows: IMyRequest[];
|
rows: IMyRequest[];
|
||||||
view: ListView;
|
view: ListView;
|
||||||
statusFilter: string;
|
statusFilter: string;
|
||||||
|
sort: ISortState;
|
||||||
|
onSort: (key: SortKey) => void;
|
||||||
onOpen: (request: IMyRequest) => void;
|
onOpen: (request: IMyRequest) => void;
|
||||||
/** Удалить черновик; задан только там, где в таблице бывают свои черновики. */
|
/** Удалить черновик; задан только там, где в таблице бывают свои черновики. */
|
||||||
onDelete?: (request: IMyRequest) => void;
|
onDelete?: (request: IMyRequest) => void;
|
||||||
}> = ({ rows, view, statusFilter, onOpen, onDelete }) => {
|
}> = ({ rows, view, statusFilter, sort, onSort, onOpen, onDelete }) => {
|
||||||
const cols = columnsFor(view, statusFilter);
|
const cols = columnsFor(view, statusFilter);
|
||||||
|
const sortable = (label: string, key: SortKey): React.ReactNode => (
|
||||||
|
<SortableHeader label={label} sortKey={key} sort={sort} onSort={onSort} />
|
||||||
|
);
|
||||||
|
|
||||||
// Колонки для DataTable строим динамически: базовые всегда, автор/статус/
|
// Колонки для DataTable строим динамически: базовые всегда, автор/статус/
|
||||||
// комментарий — по columnsFor (зависит от раздела и фильтра). Кастомные ячейки
|
// комментарий — по columnsFor (зависит от раздела и фильтра). Кастомные ячейки
|
||||||
@@ -238,10 +226,10 @@ const Table: React.FC<{
|
|||||||
// (в каждом списке SharePoint нумерация с единицы), поэтому после архивации
|
// (в каждом списке SharePoint нумерация с единицы), поэтому после архивации
|
||||||
// в таблице встречались разные заявки под одним номером. Кода нет у заявок,
|
// в таблице встречались разные заявки под одним номером. Кода нет у заявок,
|
||||||
// созданных до появления колонки — для них показываем Id, как раньше.
|
// созданных до появления колонки — для них показываем Id, как раньше.
|
||||||
{ key: 'code', title: 'Номер', width: '130px', render: (r) => r.code || `#${r.id}` },
|
{ key: 'code', title: sortable('Номер', 'code'), width: '130px', render: (r) => r.code || `#${r.id}` },
|
||||||
{ key: 'type', title: 'Тип', width: '200px', render: (r) => r.requestTypeName },
|
{ key: 'type', title: sortable('Тип', 'type'), width: '200px', render: (r) => r.requestTypeName },
|
||||||
{ key: 'title', title: 'Название', render: (r) => r.title || r.code || `#${r.id}` },
|
{ key: 'title', title: 'Название', render: (r) => r.title || r.code || `#${r.id}` },
|
||||||
{ key: 'date', title: 'Дата', width: '110px', render: (r) => formatDate(r.created) }
|
{ key: 'date', title: sortable('Дата', 'date'), width: '110px', render: (r) => formatDate(r.created) }
|
||||||
];
|
];
|
||||||
if (cols.author) {
|
if (cols.author) {
|
||||||
columns.push({
|
columns.push({
|
||||||
@@ -254,7 +242,7 @@ const Table: React.FC<{
|
|||||||
if (cols.status) {
|
if (cols.status) {
|
||||||
columns.push({
|
columns.push({
|
||||||
key: 'status',
|
key: 'status',
|
||||||
title: 'Статус',
|
title: sortable('Статус', 'status'),
|
||||||
width: '190px',
|
width: '190px',
|
||||||
render: (r) => (r.status ? <StatusBadge status={r.status} size="sm" /> : '—')
|
render: (r) => (r.status ? <StatusBadge status={r.status} size="sm" /> : '—')
|
||||||
});
|
});
|
||||||
@@ -366,6 +354,26 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
const [input, setInput] = useRoutedQuery(query, onQueryChange);
|
const [input, setInput] = useRoutedQuery(query, onQueryChange);
|
||||||
const debouncedQuery = query;
|
const debouncedQuery = query;
|
||||||
|
|
||||||
|
// Уточнён ли список (refinement.isRefined) сверх того, что умеет обычная
|
||||||
|
// постраничная загрузка. Если да — таблица грузит ПОЛНЫЙ список раздела и сама
|
||||||
|
// фильтрует, сортирует и режет его на страницы (см. fetchRefinedPage). Если
|
||||||
|
// нет — всё как раньше.
|
||||||
|
const refinement = useTableRefinement({
|
||||||
|
tableKey: `${view}|${allRequestsType?.id ?? ''}`,
|
||||||
|
statusFromRoute: view === 'own:created',
|
||||||
|
routeStatus: statusFilter,
|
||||||
|
onRouteStatusChange: onStatusFilterChange,
|
||||||
|
page,
|
||||||
|
onPageChange,
|
||||||
|
});
|
||||||
|
const { localFilters, sort, isRefined } = refinement;
|
||||||
|
const refinementSig = refinement.signature;
|
||||||
|
|
||||||
|
// Полный список раздела для режима с фильтрами — последний загруженный.
|
||||||
|
// Листание страниц и смена фильтра его переиспользуют (заново по сети не
|
||||||
|
// идём), а автообновление и rp-requests-changed перечитывают.
|
||||||
|
const fullListRef = React.useRef<{ key: string; rows: IMyRequest[] } | undefined>(undefined);
|
||||||
|
|
||||||
// Какой ИМЕННО таблице принадлежат лежащие в состоянии rows/totalRows.
|
// Какой ИМЕННО таблице принадлежат лежащие в состоянии rows/totalRows.
|
||||||
// Компонент один на все разделы (см. LIST_VIEWS в RequestPortal), поэтому при
|
// Компонент один на все разделы (см. LIST_VIEWS в RequestPortal), поэтому при
|
||||||
// переключении раздела его состояние ещё хранит данные ПРЕДЫДУЩЕГО, пока не
|
// переключении раздела его состояние ещё хранит данные ПРЕДЫДУЩЕГО, пока не
|
||||||
@@ -373,7 +381,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
// по чужому totalRows и сбивал страницу на первую — из-за чего запомненная
|
// по чужому totalRows и сбивал страницу на первую — из-за чего запомненная
|
||||||
// страница затиралась при каждом переходе по левому меню.
|
// страница затиралась при каждом переходе по левому меню.
|
||||||
// Страница в ключ НЕ входит: totalRows один на всю таблицу, а не на страницу.
|
// Страница в ключ НЕ входит: totalRows один на всю таблицу, а не на страницу.
|
||||||
const dataKey = `${view}|${statusFilter}|${debouncedQuery.trim()}|${allRequestsType?.id ?? ''}`;
|
const dataKey = `${view}|${statusFilter}|${debouncedQuery.trim()}|${allRequestsType?.id ?? ''}|${refinementSig}`;
|
||||||
const [loadedKey, setLoadedKey] = React.useState<string>('');
|
const [loadedKey, setLoadedKey] = React.useState<string>('');
|
||||||
|
|
||||||
// Страницу при смене запроса здесь НЕ сбрасываем: и запрос, и страница живут
|
// Страницу при смене запроса здесь НЕ сбрасываем: и запрос, и страница живут
|
||||||
@@ -396,7 +404,14 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (types === undefined) return;
|
if (types === undefined) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const cacheKey = pageKey('table', [view, statusFilter, debouncedQuery.trim(), page, allRequestsType?.id ?? 0]);
|
const cacheKey = pageKey('table', [
|
||||||
|
view,
|
||||||
|
statusFilter,
|
||||||
|
debouncedQuery.trim(),
|
||||||
|
page,
|
||||||
|
allRequestsType?.id ?? 0,
|
||||||
|
refinementSig,
|
||||||
|
]);
|
||||||
|
|
||||||
// Сразу показываем то, что уже загружали для ЭТОЙ вкладки (и этого запроса
|
// Сразу показываем то, что уже загружали для ЭТОЙ вкладки (и этого запроса
|
||||||
// и страницы), а обновление идёт фоном. Компонент один на обе вкладки —
|
// и страницы), а обновление идёт фоном. Компонент один на обе вкладки —
|
||||||
@@ -420,7 +435,49 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
// медленного источника. Остальные ветки (approvals/execution/поиск) его
|
// медленного источника. Остальные ветки (approvals/execution/поиск) его
|
||||||
// просто не принимают — там нет пары «Search + довесок» с независимо
|
// просто не принимают — там нет пары «Search + довесок» с независимо
|
||||||
// валидным промежуточным результатом.
|
// валидным промежуточным результатом.
|
||||||
const fetchRows = async (onPartial?: (p: IPage) => void): Promise<IPage> => {
|
// ПОЛНЫЙ список раздела (с учётом поиска и статуса из маршрута), без нарезки
|
||||||
|
// на страницы — сырьё для фильтров и сортировки. Источники те же, что у
|
||||||
|
// постраничной загрузки ниже, только без StartRow/RowLimit.
|
||||||
|
const fetchFullRows = async (): Promise<IMyRequest[]> => {
|
||||||
|
if (view === 'all') {
|
||||||
|
return allRequestsType ? getAllRequestsRowsForType(context, allRequestsType, q) : [];
|
||||||
|
}
|
||||||
|
if (view === 'rework' || view === 'own:created') {
|
||||||
|
const rows = await getAllOwnRequests(context, types, q);
|
||||||
|
const fixedStatus = view === 'rework' ? REQUEST_STATUS.returned : statusFilter;
|
||||||
|
return fixedStatus ? rows.filter((r) => r.status === fixedStatus) : rows;
|
||||||
|
}
|
||||||
|
const queue = QUEUE_BY_VIEW[view];
|
||||||
|
if (q.length === 0) return (await getApprovalQueueRows(context, queue)).rows;
|
||||||
|
const idx = await getApprovalQueueIndex(context, queue);
|
||||||
|
return filterPendingByQuery(context, idx.types, idx.rows, debouncedQuery);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fullKey = `${view}|${statusFilter}|${q}|${allRequestsType?.id ?? ''}`;
|
||||||
|
|
||||||
|
// Режим с фильтрами: полный список → фильтр → сортировка → страница.
|
||||||
|
// reuseFull — взять уже загруженный полный список, если он от этой же
|
||||||
|
// таблицы и запроса (листание, смена фильтра); иначе перечитать.
|
||||||
|
const fetchAndRememberFull = async (): Promise<IMyRequest[]> => {
|
||||||
|
const rows = await fetchFullRows();
|
||||||
|
fullListRef.current = { key: fullKey, rows };
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchRefinedPage = async (reuseFull: boolean): Promise<IPage> => {
|
||||||
|
const cachedFull = fullListRef.current;
|
||||||
|
const full =
|
||||||
|
reuseFull && cachedFull && cachedFull.key === fullKey ? cachedFull.rows : await fetchAndRememberFull();
|
||||||
|
|
||||||
|
const refined = sortRows(filterRows(full, localFilters), sort);
|
||||||
|
const pageRows = refined.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||||
|
await hydratePendingAuthors(context, types, pageRows);
|
||||||
|
return { rows: pageRows, totalRows: refined.length };
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchRows = async (onPartial?: (p: IPage) => void, reuseFull = false): Promise<IPage> => {
|
||||||
|
if (isRefined) return fetchRefinedPage(reuseFull);
|
||||||
|
|
||||||
// «Доработка» — тот же список, что «Инициированные», просто со статусом,
|
// «Доработка» — тот же список, что «Инициированные», просто со статусом,
|
||||||
// зашитым в раздел, а не выбранным в фильтре.
|
// зашитым в раздел, а не выбранным в фильтре.
|
||||||
if (view === 'rework' || (view === 'own:created' && statusFilter)) {
|
if (view === 'rework' || (view === 'own:created' && statusFilter)) {
|
||||||
@@ -462,8 +519,8 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
// для видимой страницы — как и автор в hydratePendingAuthors. Промежуточный
|
// для видимой страницы — как и автор в hydratePendingAuthors. Промежуточный
|
||||||
// показ (onPartial) комментарий ещё не несёт — он дочитывается только для
|
// показ (onPartial) комментарий ещё не несёт — он дочитывается только для
|
||||||
// ИТОГОВОГО результата, полный показ его донесёт следом.
|
// ИТОГОВОГО результата, полный показ его донесёт следом.
|
||||||
const fetchPage = async (onPartial?: (p: IPage) => void): Promise<IPage> => {
|
const fetchPage = async (onPartial?: (p: IPage) => void, reuseFull = false): Promise<IPage> => {
|
||||||
const result = await fetchRows(onPartial);
|
const result = await fetchRows(onPartial, reuseFull);
|
||||||
const decisionStatus = decisionStatusFor(view, statusFilter);
|
const decisionStatus = decisionStatusFor(view, statusFilter);
|
||||||
if (decisionStatus) {
|
if (decisionStatus) {
|
||||||
await hydrateDecisionComments(context, types, result.rows, decisionStatus);
|
await hydrateDecisionComments(context, types, result.rows, decisionStatus);
|
||||||
@@ -471,7 +528,9 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const load = async (forceRender = false): Promise<void> => {
|
// reuseFull — только для первой загрузки эффекта (см. fetchRefinedPage):
|
||||||
|
// автообновление и rp-requests-changed обязаны читать данные заново.
|
||||||
|
const load = async (forceRender = false, reuseFull = false): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
// Промежуточный показ — ТОЛЬКО когда для этой страницы прямо СЕЙЧАС
|
// Промежуточный показ — ТОЛЬКО когда для этой страницы прямо СЕЙЧАС
|
||||||
// ничего не закешировано (первый заход, экран пуст). Проверяем на
|
// ничего не закешировано (первый заход, экран пуст). Проверяем на
|
||||||
@@ -495,7 +554,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
setTotalRows(partial.totalRows);
|
setTotalRows(partial.totalRows);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fresh = await perfScope(`таблица ${view} стр.${page}`, () => fetchPage(onPartial));
|
const fresh = await perfScope(`таблица ${view} стр.${page}`, () => fetchPage(onPartial, reuseFull));
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
// Перерисовываем, только если данные реально изменились — иначе при
|
// Перерисовываем, только если данные реально изменились — иначе при
|
||||||
@@ -520,7 +579,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
load().catch(() => undefined); // ошибки уже обработаны внутри load через setError
|
load(false, true).catch(() => undefined); // ошибки уже обработаны внутри load через setError
|
||||||
const unsubscribe = onRequestsChanged((force) => {
|
const unsubscribe = onRequestsChanged((force) => {
|
||||||
// Явный повторный клик по уже открытому пункту меню — пользователь ждёт
|
// Явный повторный клик по уже открытому пункту меню — пользователь ждёт
|
||||||
// видимого обновления, а не молчаливой подмены данных за спиной. Прежние
|
// видимого обновления, а не молчаливой подмены данных за спиной. Прежние
|
||||||
@@ -541,7 +600,10 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
unsubscribe();
|
unsubscribe();
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
};
|
};
|
||||||
}, [context, types, view, statusFilter, debouncedQuery, page, dataKey, allRequestsType]);
|
// localFilters пересобирается на каждый рендер (см. выше) — в зависимостях его
|
||||||
|
// заменяет refinementSig, по которому он и меняется.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [context, types, view, statusFilter, debouncedQuery, page, dataKey, allRequestsType, isRefined, refinementSig]);
|
||||||
|
|
||||||
// Все ветки отдают уже нарезанную страницу — просто показываем как есть.
|
// Все ветки отдают уже нарезанную страницу — просто показываем как есть.
|
||||||
const q = debouncedQuery.trim();
|
const q = debouncedQuery.trim();
|
||||||
@@ -581,24 +643,6 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
{view === 'all' && allRequestsType ? `Все заявки: ${allRequestsType.name}` : HEADINGS[view]}
|
{view === 'all' && allRequestsType ? `Все заявки: ${allRequestsType.name}` : HEADINGS[view]}
|
||||||
{rows && <span className={styles.count}>{total}</span>}
|
{rows && <span className={styles.count}>{total}</span>}
|
||||||
</h2>
|
</h2>
|
||||||
{view === 'own:created' && onStatusFilterChange && (
|
|
||||||
<select
|
|
||||||
// Цвет самого <select> (закрытое состояние) — тем же классом, что и
|
|
||||||
// у выбранного <option>: statusFilter задаёт нейтральные border/фон,
|
|
||||||
// а конкретный statusOptionClassName(statusFilter) перекрывает color.
|
|
||||||
className={`${styles.statusFilter} ${statusFilter ? statusOptionClassName(statusFilter) : ''}`}
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(e) => onStatusFilterChange(e.target.value)}
|
|
||||||
aria-label="Фильтр по статусу"
|
|
||||||
>
|
|
||||||
<option value="">Все статусы</option>
|
|
||||||
{REQUEST_STATUS_CHOICES.map((s) => (
|
|
||||||
<option key={s} value={s} className={statusOptionClassName(s)}>
|
|
||||||
{s}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
className={styles.search}
|
className={styles.search}
|
||||||
@@ -608,12 +652,27 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RequestsFilterBar
|
||||||
|
filters={refinement.barFilters}
|
||||||
|
onChange={refinement.onFiltersChange}
|
||||||
|
// «Все заявки» уже показывают один выбранный тип — фильтр по типу там пуст.
|
||||||
|
types={view === 'all' ? undefined : types}
|
||||||
|
// Статус — там же, где его колонка: в остальных разделах он у всех строк один.
|
||||||
|
showStatus={columnsFor(view, '').status}
|
||||||
|
canReset={refinement.canReset}
|
||||||
|
onReset={refinement.onReset}
|
||||||
|
/>
|
||||||
|
|
||||||
{error && <div className={styles.error}>Не удалось загрузить заявки: {error}</div>}
|
{error && <div className={styles.error}>Не удалось загрузить заявки: {error}</div>}
|
||||||
{deleteError && <div className={styles.error}>{deleteError}</div>}
|
{deleteError && <div className={styles.error}>{deleteError}</div>}
|
||||||
{!error && !loaded && <div className={styles.empty}>Загрузка…</div>}
|
{!error && !loaded && <div className={styles.empty}>Загрузка…</div>}
|
||||||
{!error && loaded && total === 0 && (
|
{!error && loaded && total === 0 && (
|
||||||
<div className={styles.empty}>
|
<div className={styles.empty}>
|
||||||
{q ? `По запросу «${debouncedQuery}» ничего не найдено.` : EMPTY_TEXTS[view]}
|
{q
|
||||||
|
? `По запросу «${debouncedQuery}» ничего не найдено.`
|
||||||
|
: hasActiveFilters(refinement.barFilters)
|
||||||
|
? NO_MATCHES_TEXT
|
||||||
|
: EMPTY_TEXTS[view]}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!error && rows && total > 0 && (
|
{!error && rows && total > 0 && (
|
||||||
@@ -622,6 +681,8 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
rows={visible}
|
rows={visible}
|
||||||
view={view}
|
view={view}
|
||||||
statusFilter={statusFilter}
|
statusFilter={statusFilter}
|
||||||
|
sort={sort}
|
||||||
|
onSort={refinement.onSort}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
onDelete={view === 'own:created' ? handleDelete : undefined}
|
onDelete={view === 'own:created' ? handleDelete : undefined}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Кнопка выглядит как обычный заголовок колонки DataTable — шрифт, регистр и
|
||||||
|
// разрядку берёт у ячейки шапки, добавляет только стрелку и курсор.
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-1;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-transform: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $navy-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid $gold-500;
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
color: $navy-900;
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
color: $gold-600;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { ISortState, SortKey } from './requestsTableFilters';
|
||||||
|
import styles from './SortableHeader.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
sortKey: SortKey;
|
||||||
|
sort: ISortState;
|
||||||
|
onSort: (key: SortKey) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заголовок колонки таблицы заявок с сортировкой по клику. Кнопка, а не
|
||||||
|
// кликабельный текст: так сортировка доступна с клавиатуры, а стрелка
|
||||||
|
// показывает, по какой колонке и в какую сторону отсортировано сейчас.
|
||||||
|
export const SortableHeader: React.FC<Props> = ({ label, sortKey, sort, onSort }) => {
|
||||||
|
const isActive = sort.key === sortKey;
|
||||||
|
const arrow = !isActive ? '↕' : sort.direction === 'asc' ? '↑' : '↓';
|
||||||
|
const ariaSort = !isActive ? 'не отсортировано' : sort.direction === 'asc' ? 'по возрастанию' : 'по убыванию';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.button} ${isActive ? styles.active : ''}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
// Шапка таблицы — не строка, но клик всё равно не должен никуда всплывать.
|
||||||
|
e.stopPropagation();
|
||||||
|
onSort(sortKey);
|
||||||
|
}}
|
||||||
|
title={`Сортировать: ${label}`}
|
||||||
|
aria-label={`${label}, ${ariaSort}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<span className={styles.arrow} aria-hidden="true">
|
||||||
|
{arrow}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -414,6 +414,22 @@ export async function getApprovalQueue(
|
|||||||
kind: ApprovalQueueKind,
|
kind: ApprovalQueueKind,
|
||||||
page: number = 1
|
page: number = 1
|
||||||
): Promise<IPendingApprovalPage> {
|
): Promise<IPendingApprovalPage> {
|
||||||
|
const { types, rows, debug } = await getApprovalQueueRows(context, kind);
|
||||||
|
|
||||||
|
const pageRows = rows.slice((page - 1) * PENDING_PAGE_SIZE, page * PENDING_PAGE_SIZE);
|
||||||
|
await hydrateAuthors(context, types, pageRows);
|
||||||
|
|
||||||
|
return { rows: pageRows, totalRows: rows.length, debug };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ВСЯ очередь целиком, без нарезки на страницы, — для фильтров и сортировки
|
||||||
|
// таблицы (см. requestsTableFilters.ts): отобрать и упорядочить честно можно
|
||||||
|
// только полный набор, а не одну страницу. Автор не дочитывается — его
|
||||||
|
// дочитывает вызывающая сторона для видимой страницы (hydratePendingAuthors).
|
||||||
|
export async function getApprovalQueueRows(
|
||||||
|
context: WebPartContext,
|
||||||
|
kind: ApprovalQueueKind
|
||||||
|
): Promise<{ rows: IMyRequest[]; types: IRequestType[]; debug: IMyRequestsTypeDebug[] }> {
|
||||||
// Очереди действий («На согласовании» и «На исполнении») собираются ОДНОЙ
|
// Очереди действий («На согласовании» и «На исполнении») собираются ОДНОЙ
|
||||||
// выборкой по обоим статусам, а разносятся уже по myStepStatus — ровно так же,
|
// выборкой по обоим статусам, а разносятся уже по myStepStatus — ровно так же,
|
||||||
// как это делают счётчики меню (см. getNavCountsInner). Смысл: это тот же
|
// как это делают счётчики меню (см. getNavCountsInner). Смысл: это тот же
|
||||||
@@ -433,10 +449,7 @@ export async function getApprovalQueue(
|
|||||||
// finalizeApprovalTarget перечитывает статус REST-ом.
|
// finalizeApprovalTarget перечитывает статус REST-ом.
|
||||||
const rows = shared ? index.filter((r) => r.myStepStatus === QUEUE_STATUSES[kind][0]) : index;
|
const rows = shared ? index.filter((r) => r.myStepStatus === QUEUE_STATUSES[kind][0]) : index;
|
||||||
|
|
||||||
const pageRows = rows.slice((page - 1) * PENDING_PAGE_SIZE, page * PENDING_PAGE_SIZE);
|
return { rows, types, debug };
|
||||||
await hydrateAuthors(context, types, pageRows);
|
|
||||||
|
|
||||||
return { rows: pageRows, totalRows: rows.length, debug };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// С текстовым поиском: полный индекс (без автора) — фильтрует его вызывающая
|
// С текстовым поиском: полный индекс (без автора) — фильтрует его вызывающая
|
||||||
@@ -1409,6 +1422,18 @@ export async function getAllRequestsForType(
|
|||||||
page: number,
|
page: number,
|
||||||
query: string
|
query: string
|
||||||
): Promise<IOwnRequestsPage> {
|
): Promise<IOwnRequestsPage> {
|
||||||
|
const rows = await getAllRequestsRowsForType(context, type, query);
|
||||||
|
return { rows: rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), totalRows: rows.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Весь набор «Все заявки» одного типа, без нарезки на страницы — его же режет
|
||||||
|
// getAllRequestsForType, и его же фильтрует/сортирует таблица (см.
|
||||||
|
// requestsTableFilters.ts).
|
||||||
|
export async function getAllRequestsRowsForType(
|
||||||
|
context: WebPartContext,
|
||||||
|
type: IRequestType,
|
||||||
|
query: string
|
||||||
|
): Promise<IMyRequest[]> {
|
||||||
const sp = spForSite(type.siteUrl as string, context);
|
const sp = spForSite(type.siteUrl as string, context);
|
||||||
const clauses = [pathClauseForTypes([type])];
|
const clauses = [pathClauseForTypes([type])];
|
||||||
const text = freeTextClause(query);
|
const text = freeTextClause(query);
|
||||||
@@ -1421,9 +1446,7 @@ export async function getAllRequestsForType(
|
|||||||
|
|
||||||
// kind: 'other' — заявка не обязательно текущего пользователя, открывается
|
// kind: 'other' — заявка не обязательно текущего пользователя, открывается
|
||||||
// только на просмотр (см. IMyRequest.kind, openRequest в RequestPortal.tsx).
|
// только на просмотр (см. IMyRequest.kind, openRequest в RequestPortal.tsx).
|
||||||
const rows = hitsToEntries({ PrimarySearchResults: hits }, [type]).map((e) => toMyRequestFromSearch(e, 'other'));
|
return hitsToEntries({ PrimarySearchResults: hits }, [type]).map((e) => toMyRequestFromSearch(e, 'other'));
|
||||||
|
|
||||||
return { rows: rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), totalRows: rows.length };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Свои заявки с конкретным статусом ЗАЯВКИ (не строки маршрута) — «Доработка»
|
// Свои заявки с конкретным статусом ЗАЯВКИ (не строки маршрута) — «Доработка»
|
||||||
@@ -1516,6 +1539,45 @@ async function ownRequestsByStatus(
|
|||||||
return raceThenMerge(searchPromise, freshPromise, onPartial);
|
return raceThenMerge(searchPromise, freshPromise, onPartial);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ВСЕ свои заявки (любого статуса), без нарезки на страницы — для фильтров и
|
||||||
|
* сортировки таблицы (см. requestsTableFilters.ts). Отбор по статусу делает
|
||||||
|
* вызывающая сторона в памяти.
|
||||||
|
*
|
||||||
|
* Search находит весь архив (searchOwnAll), а без текстового запроса к нему
|
||||||
|
* подмешивается REST-довесок по свежести — тот же, что у постраничных
|
||||||
|
* getOwnRequests (новые заявки и черновики) и getOwnRequestsByStatus
|
||||||
|
* (недавно изменённые, напр. только что возвращённые на доработку). У одной и
|
||||||
|
* той же заявки довесок свежее индекса, поэтому при совпадении берётся он.
|
||||||
|
*/
|
||||||
|
export async function getAllOwnRequests(
|
||||||
|
context: WebPartContext,
|
||||||
|
types: IRequestType[],
|
||||||
|
query: string
|
||||||
|
): Promise<IMyRequest[]> {
|
||||||
|
if (types.length === 0) return [];
|
||||||
|
|
||||||
|
const searchPromise = searchOwnAll(context, types, query);
|
||||||
|
if (query.trim()) return searchPromise;
|
||||||
|
|
||||||
|
const freshPromise = Promise.all(
|
||||||
|
types.map(async (t) => {
|
||||||
|
const [created, changed] = await Promise.all([
|
||||||
|
getFreshOwnRowsForType(context, t),
|
||||||
|
getRecentlyChangedOwnRowsForType(context, t),
|
||||||
|
]);
|
||||||
|
return created.concat(changed);
|
||||||
|
})
|
||||||
|
).then((r) => r.reduce((all, x) => all.concat(x), [] as IMyRequest[]));
|
||||||
|
|
||||||
|
const [searchRows, freshRows] = await Promise.all([searchPromise, freshPromise]);
|
||||||
|
|
||||||
|
const byKey = new Map<string, IMyRequest>();
|
||||||
|
searchRows.forEach((r) => byKey.set(rowKey(r), r));
|
||||||
|
freshRows.forEach((r) => byKey.set(rowKey(r), r));
|
||||||
|
return Array.from(byKey.values()).sort((a, b) => b.created.localeCompare(a.created));
|
||||||
|
}
|
||||||
|
|
||||||
// Сколько строк маршрута максимум забираем за раз при дочитывании комментариев:
|
// Сколько строк маршрута максимум забираем за раз при дочитывании комментариев:
|
||||||
// на странице ≤20 заявок, у каждой обычно единицы шагов — запас многократный.
|
// на странице ≤20 заявок, у каждой обычно единицы шагов — запас многократный.
|
||||||
const DECISION_COMMENT_ROW_LIMIT = 2000;
|
const DECISION_COMMENT_ROW_LIMIT = 2000;
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { IMyRequest } from './models/IMyRequest';
|
||||||
|
import {
|
||||||
|
DEFAULT_SORT,
|
||||||
|
EMPTY_FILTERS,
|
||||||
|
filterRows,
|
||||||
|
hasActiveFilters,
|
||||||
|
isDefaultSort,
|
||||||
|
localDateKey,
|
||||||
|
sortRows,
|
||||||
|
toggleSort,
|
||||||
|
} from './requestsTableFilters';
|
||||||
|
|
||||||
|
function request(over: Partial<IMyRequest> = {}): IMyRequest {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
code: '4-2609-1',
|
||||||
|
title: 'Заявка',
|
||||||
|
status: 'Черновик',
|
||||||
|
authorEmail: 'ivanov@tnsu.kz',
|
||||||
|
authorName: 'Иванов Иван',
|
||||||
|
created: '2026-09-24T10:00:00',
|
||||||
|
requestTypeId: 4,
|
||||||
|
requestTypeName: 'Заявка на подбор',
|
||||||
|
section: 'Кадры',
|
||||||
|
listId: '/sites/requests/Lists/List4',
|
||||||
|
siteUrl: 'https://tnsukz.sharepoint.com/sites/requests',
|
||||||
|
formKey: 'recruiting-selection',
|
||||||
|
kind: 'own',
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const codes = (rows: IMyRequest[]): string[] => rows.map((r) => r.code);
|
||||||
|
|
||||||
|
describe('toggleSort', () => {
|
||||||
|
it('по той же колонке меняет направление', () => {
|
||||||
|
expect(toggleSort({ key: 'date', direction: 'desc' }, 'date')).toEqual({ key: 'date', direction: 'asc' });
|
||||||
|
expect(toggleSort({ key: 'date', direction: 'asc' }, 'date')).toEqual({ key: 'date', direction: 'desc' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('по новой колонке начинает с возрастания', () => {
|
||||||
|
expect(toggleSort(DEFAULT_SORT, 'code')).toEqual({ key: 'code', direction: 'asc' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasActiveFilters / isDefaultSort', () => {
|
||||||
|
it('пустые фильтры и сортировка по умолчанию не считаются уточнением', () => {
|
||||||
|
expect(hasActiveFilters(EMPTY_FILTERS)).toBe(false);
|
||||||
|
expect(isDefaultSort(DEFAULT_SORT)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('любое заполненное поле — активный фильтр', () => {
|
||||||
|
expect(hasActiveFilters({ ...EMPTY_FILTERS, typeId: 4 })).toBe(true);
|
||||||
|
expect(hasActiveFilters({ ...EMPTY_FILTERS, dateTo: '2026-09-30' })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('localDateKey', () => {
|
||||||
|
it('отдаёт местную дату в виде YYYY-MM-DD', () => {
|
||||||
|
expect(localDateKey('2026-09-05T10:00:00')).toBe('2026-09-05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пустая или битая дата — пустая строка', () => {
|
||||||
|
expect(localDateKey('')).toBe('');
|
||||||
|
expect(localDateKey('не дата')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filterRows', () => {
|
||||||
|
const rows = [
|
||||||
|
request({ code: '4-2609-1', requestTypeId: 4, status: 'Черновик', created: '2026-09-20T10:00:00' }),
|
||||||
|
request({ code: '5-2609-2', requestTypeId: 5, status: 'На согласовании', created: '2026-09-24T10:00:00' }),
|
||||||
|
request({ code: '4-2609-3', requestTypeId: 4, status: 'На согласовании', created: '2026-09-26T10:00:00' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
it('без фильтров возвращает всё', () => {
|
||||||
|
expect(filterRows(rows, EMPTY_FILTERS)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('по типу и статусу одновременно', () => {
|
||||||
|
expect(codes(filterRows(rows, { ...EMPTY_FILTERS, typeId: 4, status: 'На согласовании' }))).toEqual([
|
||||||
|
'4-2609-3',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('период включает обе границы', () => {
|
||||||
|
expect(codes(filterRows(rows, { ...EMPTY_FILTERS, dateFrom: '2026-09-24', dateTo: '2026-09-26' }))).toEqual([
|
||||||
|
'5-2609-2',
|
||||||
|
'4-2609-3',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('заявка без даты не проходит фильтр по периоду', () => {
|
||||||
|
expect(filterRows([request({ created: '' })], { ...EMPTY_FILTERS, dateFrom: '2026-01-01' })).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sortRows', () => {
|
||||||
|
it('номер сравнивается по числам, а не строкой', () => {
|
||||||
|
const rows = [request({ code: '4-2609-100' }), request({ code: '4-2609-23' }), request({ code: '4-2608-500' })];
|
||||||
|
expect(codes(sortRows(rows, { key: 'code', direction: 'asc' }))).toEqual([
|
||||||
|
'4-2608-500',
|
||||||
|
'4-2609-23',
|
||||||
|
'4-2609-100',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('заявка без кода сортируется по Id', () => {
|
||||||
|
const rows = [request({ code: '', id: 7 }), request({ code: '', id: 3 })];
|
||||||
|
expect(sortRows(rows, { key: 'code', direction: 'desc' }).map((r) => r.id)).toEqual([7, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('при равенстве по колонке — свежие сверху', () => {
|
||||||
|
const rows = [
|
||||||
|
request({ code: 'a', requestTypeName: 'Отпуск', created: '2026-09-01T10:00:00' }),
|
||||||
|
request({ code: 'b', requestTypeName: 'Отпуск', created: '2026-09-02T10:00:00' }),
|
||||||
|
];
|
||||||
|
expect(codes(sortRows(rows, { key: 'type', direction: 'asc' }))).toEqual(['b', 'a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не мутирует исходный массив', () => {
|
||||||
|
const rows = [request({ code: '4-2609-2' }), request({ code: '4-2609-1' })];
|
||||||
|
sortRows(rows, { key: 'code', direction: 'asc' });
|
||||||
|
expect(codes(rows)).toEqual(['4-2609-2', '4-2609-1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { IMyRequest } from './models/IMyRequest';
|
||||||
|
|
||||||
|
// Фильтры и сортировка таблиц заявок (RequestsTableView). Чистые функции без
|
||||||
|
// React и без сети — таблица применяет их к ПОЛНОМУ списку раздела, а не к
|
||||||
|
// одной странице (иначе отсортировались бы 20 строк, а не все заявки).
|
||||||
|
|
||||||
|
/** Колонки, по которым можно сортировать. */
|
||||||
|
export type SortKey = 'code' | 'type' | 'date' | 'status';
|
||||||
|
export type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
|
export interface ISortState {
|
||||||
|
key: SortKey;
|
||||||
|
direction: SortDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITableFilters {
|
||||||
|
/** Id типа заявки; undefined — все типы. */
|
||||||
|
typeId: number | undefined;
|
||||||
|
/** Статус заявки; '' — все статусы. */
|
||||||
|
status: string;
|
||||||
|
/** Период по дате создания, 'YYYY-MM-DD' (значение <input type="date">); '' — без границы. */
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Порядок по умолчанию — свежие сверху, как таблицы сортировались всегда. */
|
||||||
|
export const DEFAULT_SORT: ISortState = { key: 'date', direction: 'desc' };
|
||||||
|
|
||||||
|
export const EMPTY_FILTERS: ITableFilters = { typeId: undefined, status: '', dateFrom: '', dateTo: '' };
|
||||||
|
|
||||||
|
export function hasActiveFilters(filters: ITableFilters): boolean {
|
||||||
|
return filters.typeId !== undefined || !!filters.status || !!filters.dateFrom || !!filters.dateTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDefaultSort(sort: ISortState): boolean {
|
||||||
|
return sort.key === DEFAULT_SORT.key && sort.direction === DEFAULT_SORT.direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Следующее состояние сортировки по клику на заголовок: по той же колонке —
|
||||||
|
* смена направления, по другой — сначала по возрастанию.
|
||||||
|
*/
|
||||||
|
export function toggleSort(current: ISortState, key: SortKey): ISortState {
|
||||||
|
if (current.key === key) {
|
||||||
|
return { key, direction: current.direction === 'asc' ? 'desc' : 'asc' };
|
||||||
|
}
|
||||||
|
return { key, direction: 'asc' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дата создания в МЕСТНОМ времени как 'YYYY-MM-DD' — в том же виде, что
|
||||||
|
* значение <input type="date">. Created хранится в UTC: заявка, созданная в
|
||||||
|
* 01:00 по Алматы, в UTC ещё «вчерашняя», и сравнение по сырой ISO-строке
|
||||||
|
* выкинуло бы её из фильтра за сегодня.
|
||||||
|
*/
|
||||||
|
export function localDateKey(iso: string): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return '';
|
||||||
|
const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`;
|
||||||
|
const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`;
|
||||||
|
return `${d.getFullYear()}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterRows(rows: IMyRequest[], filters: ITableFilters): IMyRequest[] {
|
||||||
|
return rows.filter((r) => {
|
||||||
|
if (filters.typeId !== undefined && r.requestTypeId !== filters.typeId) return false;
|
||||||
|
if (filters.status && r.status !== filters.status) return false;
|
||||||
|
if (filters.dateFrom || filters.dateTo) {
|
||||||
|
// Строки 'YYYY-MM-DD' одинаковой длины сравниваются как даты.
|
||||||
|
const day = localDateKey(r.created);
|
||||||
|
if (!day) return false;
|
||||||
|
if (filters.dateFrom && day < filters.dateFrom) return false;
|
||||||
|
if (filters.dateTo && day > filters.dateTo) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Части номера заявки как числа: «4-2609-42» → [4, 2609, 42]. Сравнивать номер
|
||||||
|
* строкой нельзя — «4-2609-100» оказался бы раньше «4-2609-23». У заявок без
|
||||||
|
* кода (созданы до появления колонки) номер — Id элемента.
|
||||||
|
*/
|
||||||
|
function codeParts(r: IMyRequest): number[] {
|
||||||
|
if (!r.code) return [r.id];
|
||||||
|
return r.code.split('-').map((part) => Number(part) || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareCodes(a: IMyRequest, b: IMyRequest): number {
|
||||||
|
const pa = codeParts(a);
|
||||||
|
const pb = codeParts(b);
|
||||||
|
const length = Math.max(pa.length, pb.length);
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
||||||
|
if (diff !== 0) return diff;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareBy(key: SortKey, a: IMyRequest, b: IMyRequest): number {
|
||||||
|
switch (key) {
|
||||||
|
case 'code':
|
||||||
|
return compareCodes(a, b);
|
||||||
|
case 'type':
|
||||||
|
return a.requestTypeName.localeCompare(b.requestTypeName, 'ru');
|
||||||
|
case 'status':
|
||||||
|
return a.status.localeCompare(b.status, 'ru');
|
||||||
|
case 'date':
|
||||||
|
return a.created.localeCompare(b.created);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Новый массив; при равенстве по выбранной колонке — свежие сверху. */
|
||||||
|
export function sortRows(rows: IMyRequest[], sort: ISortState): IMyRequest[] {
|
||||||
|
const sign = sort.direction === 'asc' ? 1 : -1;
|
||||||
|
return [...rows].sort((a, b) => {
|
||||||
|
const primary = compareBy(sort.key, a, b) * sign;
|
||||||
|
return primary !== 0 ? primary : b.created.localeCompare(a.created);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строка-ключ состояния — для ключей кеша страниц и загрузки таблицы. */
|
||||||
|
export function refinementSignature(filters: ITableFilters, sort: ISortState): string {
|
||||||
|
return [filters.typeId ?? '', filters.status, filters.dateFrom, filters.dateTo, sort.key, sort.direction].join('|');
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import {
|
||||||
|
DEFAULT_SORT,
|
||||||
|
EMPTY_FILTERS,
|
||||||
|
hasActiveFilters,
|
||||||
|
isDefaultSort,
|
||||||
|
ISortState,
|
||||||
|
ITableFilters,
|
||||||
|
refinementSignature,
|
||||||
|
SortKey,
|
||||||
|
toggleSort,
|
||||||
|
} from './requestsTableFilters';
|
||||||
|
|
||||||
|
/** Фильтры и сортировка одной таблицы (см. requestsTableFilters.ts). */
|
||||||
|
interface IRefinement {
|
||||||
|
filters: ITableFilters;
|
||||||
|
sort: ISortState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_REFINEMENT: IRefinement = { filters: EMPTY_FILTERS, sort: DEFAULT_SORT };
|
||||||
|
|
||||||
|
interface IOptions {
|
||||||
|
/** Ключ таблицы: раздел (+ тип для «Все заявки»). */
|
||||||
|
tableKey: string;
|
||||||
|
/** Статус живёт в маршруте, а не здесь (вкладка «Инициированные»). */
|
||||||
|
statusFromRoute: boolean;
|
||||||
|
routeStatus: string;
|
||||||
|
onRouteStatusChange?: (status: string) => void;
|
||||||
|
page: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITableRefinement {
|
||||||
|
/** Фильтры, которые таблица применяет сама (без статуса из маршрута). */
|
||||||
|
localFilters: ITableFilters;
|
||||||
|
/** То, что показывает панель фильтров (со статусом из маршрута). */
|
||||||
|
barFilters: ITableFilters;
|
||||||
|
sort: ISortState;
|
||||||
|
/** Список уточнён сверх обычной постраничной загрузки — нужен полный набор. */
|
||||||
|
isRefined: boolean;
|
||||||
|
/** Строка-ключ состояния для кешей и зависимостей загрузки. */
|
||||||
|
signature: string;
|
||||||
|
canReset: boolean;
|
||||||
|
onFiltersChange: (filters: ITableFilters) => void;
|
||||||
|
onSort: (key: SortKey) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние фильтров и сортировки таблицы заявок.
|
||||||
|
*
|
||||||
|
* СВОЁ у каждой таблицы: RequestsTableView один на все разделы, и без ключа по
|
||||||
|
* разделу фильтр «Тип: Отпуск» из «Моих заявок» переезжал бы в «На
|
||||||
|
* согласовании». Живёт, пока смонтирована таблица: переживает открытие заявки
|
||||||
|
* и переходы между её разделами, но не уход на «Поиск»/«Новую заявку».
|
||||||
|
*
|
||||||
|
* Статус на «Инициированных» живёт в маршруте (так было и до фильтров) и
|
||||||
|
* отбирается сервером — здесь его не дублируем, чтобы не было двух источников истины.
|
||||||
|
*/
|
||||||
|
export function useTableRefinement(options: IOptions): ITableRefinement {
|
||||||
|
const { tableKey, statusFromRoute, routeStatus, onRouteStatusChange, page, onPageChange } = options;
|
||||||
|
const [byTable, setByTable] = React.useState<Record<string, IRefinement>>({});
|
||||||
|
const refinement = byTable[tableKey] ?? DEFAULT_REFINEMENT;
|
||||||
|
const { sort } = refinement;
|
||||||
|
|
||||||
|
const localFilters: ITableFilters = statusFromRoute ? { ...refinement.filters, status: '' } : refinement.filters;
|
||||||
|
const barFilters: ITableFilters = statusFromRoute ? { ...localFilters, status: routeStatus } : localFilters;
|
||||||
|
const isRefined = hasActiveFilters(localFilters) || !isDefaultSort(sort);
|
||||||
|
|
||||||
|
// Другой набор строк — прежний номер страницы в нём ничего не значит, поэтому
|
||||||
|
// страница сбрасывается на первую. Кроме случая, когда заодно меняется статус
|
||||||
|
// в маршруте: onRouteStatusChange сам ведёт на первую страницу, а второй
|
||||||
|
// переход (onPageChange) ушёл бы от ещё старого маршрута и откатил бы статус.
|
||||||
|
const save = (next: IRefinement, routeStatusChanged: boolean): void => {
|
||||||
|
setByTable((prev) => ({ ...prev, [tableKey]: next }));
|
||||||
|
if (!routeStatusChanged && page !== 1) onPageChange(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeRouteStatus = (status: string): boolean => {
|
||||||
|
if (!statusFromRoute || status === routeStatus || !onRouteStatusChange) return false;
|
||||||
|
onRouteStatusChange(status);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
localFilters,
|
||||||
|
barFilters,
|
||||||
|
sort,
|
||||||
|
isRefined,
|
||||||
|
signature: refinementSignature(localFilters, sort),
|
||||||
|
canReset: isRefined || (statusFromRoute && !!routeStatus),
|
||||||
|
onFiltersChange: (next) => {
|
||||||
|
const routeStatusChanged = changeRouteStatus(next.status);
|
||||||
|
save({ ...refinement, filters: { ...next, status: statusFromRoute ? '' : next.status } }, routeStatusChanged);
|
||||||
|
},
|
||||||
|
onSort: (key) => save({ ...refinement, sort: toggleSort(sort, key) }, false),
|
||||||
|
onReset: () => save(DEFAULT_REFINEMENT, changeRouteStatus('')),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,9 +17,9 @@ export function hireApprovalService(listId?: string): ApprovalListService {
|
|||||||
return approvalServiceFor(FORM_ID, listId);
|
return approvalServiceFor(FORM_ID, listId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Название заявки (Title): «Прием — ФИО кандидата — Должность». */
|
/** Название заявки (Title): «ФИО кандидата — Должность — Проект». */
|
||||||
function buildTitle(data: IFormData): string {
|
function buildTitle(data: IFormData): string {
|
||||||
return buildRequestTitle('Прием', [data.HireCandidateName, data.Position]);
|
return buildRequestTitle([data.HireCandidateName, data.Position, data.Project]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Поля элемента списка данных из модели формы (текст/выбор/даты/числа), без статуса. */
|
/** Поля элемента списка данных из модели формы (текст/выбор/даты/числа), без статуса. */
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ async function resolveUserId(login: string): Promise<number> {
|
|||||||
return ensured.Id;
|
return ensured.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Название заявки (Title): «Подбор — Должность — Объект/Проект». */
|
/** Название заявки (Title): «Должность — Объект/Проект». */
|
||||||
function buildTitle(data: IFormData): string {
|
function buildTitle(data: IFormData): string {
|
||||||
return buildRequestTitle('Подбор', [data.Position, data.ObjectProject]);
|
return buildRequestTitle([data.Position, data.ObjectProject]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Скалярные/числовые/да-нет поля из модели + название (людей и статус пропускаем — они отдельно). */
|
/** Скалярные/числовые/да-нет поля из модели + название (людей и статус пропускаем — они отдельно). */
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { buildRequestTitle } from './requestTitle';
|
import { buildRequestTitle } from './requestTitle';
|
||||||
|
|
||||||
describe('buildRequestTitle', () => {
|
describe('buildRequestTitle', () => {
|
||||||
it('склеивает тип и части через тире', () => {
|
it('склеивает части через тире', () => {
|
||||||
expect(buildRequestTitle('Подбор', ['Инженер', 'Проект X'])).toBe('Подбор — Инженер — Проект X');
|
expect(buildRequestTitle(['Инженер', 'Проект X'])).toBe('Инженер — Проект X');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('пропускает пустые и пробельные части', () => {
|
it('пропускает пустые и пробельные части', () => {
|
||||||
expect(buildRequestTitle('Подбор', ['', ' Проект X '])).toBe('Подбор — Проект X');
|
expect(buildRequestTitle(['', ' Проект X '])).toBe('Проект X');
|
||||||
expect(buildRequestTitle('Прием', ['', ''])).toBe('Прием');
|
expect(buildRequestTitle(['Иванов Иван', '', ''])).toBe('Иванов Иван');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
const TITLE_SEPARATOR = ' — ';
|
const TITLE_SEPARATOR = ' — ';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Название заявки (системная колонка Title списка данных): тип + ключевые
|
* Название заявки (системная колонка Title списка данных): ключевые поля через
|
||||||
* поля через тире, пустые части пропускаются — «Подбор — Инженер — Проект X».
|
* тире, пустые части пропускаются — «Инженер — Проект X». По нему заявку узнают в списке SP, в столбце «Заявка» списка согласования
|
||||||
* По нему заявку узнают в списке SP, в столбце «Заявка» списка согласования
|
|
||||||
* и в колонке «Название» таблиц портала.
|
* и в колонке «Название» таблиц портала.
|
||||||
*/
|
*/
|
||||||
export function buildRequestTitle(typeLabel: string, parts: string[]): string {
|
export function buildRequestTitle(parts: string[]): string {
|
||||||
return [typeLabel, ...parts.map((p) => p.trim())].filter(Boolean).join(TITLE_SEPARATOR);
|
return parts.map((p) => p.trim()).filter(Boolean).join(TITLE_SEPARATOR);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user