ea07e8d5ab
Столбец «Исполнитель» показывает только текущего держателя, и было не видно, кто из исполнителей уже отметил «Выполнено». Теперь видно ФИО и время каждой отметки; столбец появляется, только если на странице есть такие. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
853 lines
49 KiB
TypeScript
853 lines
49 KiB
TypeScript
import * as React from 'react';
|
||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||
import {
|
||
ApprovalQueueKind,
|
||
filterPendingByQuery,
|
||
getAllRequestsForType,
|
||
getAllOwnRequests,
|
||
getAllRequestsRowsForType,
|
||
getApprovalQueue,
|
||
getApprovalQueueIndex,
|
||
getApprovalQueueRows,
|
||
getLinkedTypes,
|
||
getOwnRequests,
|
||
getOwnRequestsByStatus,
|
||
hydrateCurrentAssignees,
|
||
hydrateDecisionComments,
|
||
hydrateExecutedSteps,
|
||
hydratePendingAuthors,
|
||
searchOwnRequests,
|
||
} from './myRequests.service';
|
||
import { hydrateStartDates } from './startDates.service';
|
||
import { IMyRequest } from './models/IMyRequest';
|
||
import { DataTable, IDataTableColumn, IconButton } from '@tnsu/ui-kit';
|
||
import { REQUEST_STATUS, IRequestType, StatusBadge } from '../../shared/approval';
|
||
import { confirmAndDeleteDraft, isDeletableStatus } from '../../shared/draftDeletion';
|
||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||
import { getFormComponent } from '../../forms/registry';
|
||
import { FORM_ID as WORKPLACE_SETUP_FORM_ID } from '../../forms/WorkplaceSetup/model';
|
||
import { onRequestsChanged } from '../../shared/requestEvents';
|
||
import { perfScope } from '../../shared/perfTrace';
|
||
import { useRoutedQuery } from '../../shared/usePersistedQuery';
|
||
import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||
import { Pager } from './Pager';
|
||
import { RequestsFilterBar } from './RequestsFilterBar';
|
||
import { ActiveFiltersBar } from './ActiveFiltersBar';
|
||
import { effectiveUserEmail } from '../../shared/impersonation';
|
||
import { RequestHistoryDialog } from './RequestHistoryDialog';
|
||
import { SortableHeader } from './SortableHeader';
|
||
import { filterRows, hasActiveFilters, ISortState, SortKey, sortRows } from './requestsTableFilters';
|
||
import { useTableRefinement } from './useTableRefinement';
|
||
import { formatDateTime } from '../../shared/ui/FieldChanges/formatDateTime';
|
||
import styles from './RequestsTableView.module.scss';
|
||
|
||
const PAGE_SIZE = 20;
|
||
|
||
// Автообновление открытых таблиц: раз в 10 минут перечитываем данные, чтобы
|
||
// вкладка, оставленная открытой надолго, не показывала устаревший список.
|
||
// Обновление фоновое — строки с экрана не пропадают и перерисовываются, только
|
||
// если данные реально изменились (см. pageSignature).
|
||
const AUTO_REFRESH_MS = 10 * 60 * 1000;
|
||
|
||
|
||
/**
|
||
* Что именно показывает таблица. Один компонент обслуживает все списки заявок
|
||
* портала, а различаются они только источником строк и набором колонок:
|
||
*
|
||
* 'approvals' | 'execution' — очереди действий (из строк маршрута);
|
||
* 'rework' — свои заявки, возвращённые на доработку;
|
||
* 'own:created' — свои заявки (+ фильтр по статусу);
|
||
* 'own:approved'|'own:rejected'|'own:executed' — история своих решений.
|
||
*/
|
||
export type ListView =
|
||
| 'approvals'
|
||
| 'execution'
|
||
| 'rework'
|
||
| 'own:created'
|
||
| 'own:approved'
|
||
| 'own:rejected'
|
||
| 'own:executed'
|
||
| 'all';
|
||
|
||
// Представления, которые строятся на строках маршрута пользователя, и их очередь
|
||
// в сервисе. Всё, чего здесь нет ('rework'/'own:created'), читается из списка
|
||
// данных по автору.
|
||
const QUEUE_BY_VIEW: { [key: string]: ApprovalQueueKind } = {
|
||
approvals: 'approvals',
|
||
execution: 'execution',
|
||
'own:approved': 'approved',
|
||
'own:rejected': 'rejected',
|
||
'own:executed': 'executed',
|
||
};
|
||
|
||
// 'all' — заголовок и текст пустоты зависят от выбранного типа заявки
|
||
// (см. allRequestsType и heading/emptyText ниже), здесь только запасной
|
||
// вариант на случай, пока тип ещё не выбран/не загрузился.
|
||
const HEADINGS: Record<ListView, string> = {
|
||
approvals: 'Ждут вашего согласования',
|
||
execution: 'Ждут вашего исполнения',
|
||
rework: 'Возвращены вам на доработку',
|
||
'own:created': 'Инициированные мной',
|
||
'own:approved': 'Согласованные мной',
|
||
'own:rejected': 'Отклонённые мной',
|
||
'own:executed': 'Выполненные мной',
|
||
all: 'Все заявки',
|
||
};
|
||
|
||
const EMPTY_TEXTS: Record<ListView, string> = {
|
||
approvals: 'Нет заявок, ждущих вашего согласования.',
|
||
execution: 'Нет заявок, ждущих вашего исполнения.',
|
||
rework: 'Нет заявок, возвращённых вам на доработку.',
|
||
'own:created': 'Вы ещё не создавали заявок.',
|
||
'own:approved': 'Вы ещё ничего не согласовали.',
|
||
'own:rejected': 'Вы ещё ничего не отклоняли.',
|
||
'own:executed': 'Вы ещё ничего не выполнили.',
|
||
all: 'Заявок этого типа пока нет.',
|
||
};
|
||
|
||
const NO_MATCHES_TEXT = 'Нет заявок, подходящих под выбранные фильтры.';
|
||
|
||
// Колонки: «Автор» нужен там, где заявка чужая; «Статус» — где он не следует из
|
||
// самого раздела. В истории решений важно и то, и другое: заявка чужая, а её
|
||
// текущий статус мог уйти дальше вашего решения.
|
||
//
|
||
// 'rework' — свои заявки (автор не нужен) И все с одним статусом «Возвращена на
|
||
// доработку», раз именно по нему раздел и отобран (см. getOwnRequestsByStatus):
|
||
// колонка из одинаковых плашек ничего не сообщает, только шумит. Та же логика,
|
||
// что у очередей согласования/исполнения.
|
||
//
|
||
// 'own:created' — статус нужен, только пока показаны ВСЕ заявки. Стоит выбрать
|
||
// его в фильтре, и колонка вырождается в тот же столбец одинаковых плашек,
|
||
// поэтому она зависит не от одного вида, а ещё и от фильтра.
|
||
//
|
||
// Статус решения, ОБЩИЙ для всех строк раздела, — если он есть, показывать его
|
||
// колонкой незачем (столбец одинаковых плашек), зато полезен комментарий
|
||
// согласующего: почему вернули или почему отклонили. Именно за этим в такие
|
||
// списки и заходят.
|
||
function decisionStatusFor(view: ListView, statusFilter: string): string | undefined {
|
||
if (view === 'rework') return REQUEST_STATUS.returned;
|
||
if (view === 'own:rejected') return REQUEST_STATUS.rejected;
|
||
if (
|
||
view === 'own:created' &&
|
||
(statusFilter === REQUEST_STATUS.returned || statusFilter === REQUEST_STATUS.rejected)
|
||
) {
|
||
return statusFilter;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
interface ITableColumns {
|
||
author: boolean;
|
||
status: boolean;
|
||
/** Колонка «Исполнитель» — у кого заявка сейчас (см. showsAssignee). */
|
||
assignee: boolean;
|
||
/** Колонка «Исполнено» — кто из исполнителей уже выполнил (см. showsExecuted). */
|
||
executed: boolean;
|
||
/** Колонка «Дата выхода» — только «Все заявки» по организации рабочего места. */
|
||
startDate?: boolean;
|
||
/** Заголовок колонки с комментарием; undefined — колонки нет. */
|
||
commentLabel?: string;
|
||
}
|
||
|
||
// «Исполнитель» нужен там, где заявка может быть у кого угодно: свои поданные
|
||
// заявки, история своих решений (заявка ушла дальше по маршруту) и «Все
|
||
// заявки». В очередях согласования/исполнения и в «Доработке» держатель —
|
||
// всегда сам пользователь, в «Отклонённых мной» — никто: колонка вышла бы из
|
||
// одинаковых значений.
|
||
//
|
||
// На «Инициированных» с фильтром по статусу — только если при этом статусе у
|
||
// заявки вообще бывает держатель по маршруту. При «Возвращена на доработку»
|
||
// это всегда сам автор, при итоговых статусах — никто.
|
||
const ASSIGNEE_VIEWS: ListView[] = ['own:created', 'own:approved', 'own:executed', 'all'];
|
||
const STATUSES_WITH_ROUTE_HOLDER: string[] = [REQUEST_STATUS.onApproval, REQUEST_STATUS.execution];
|
||
|
||
function showsAssignee(view: ListView, statusFilter: string): boolean {
|
||
if (ASSIGNEE_VIEWS.indexOf(view) < 0) return false;
|
||
if (view === 'own:created' && statusFilter) return STATUSES_WITH_ROUTE_HOLDER.indexOf(statusFilter) >= 0;
|
||
return true;
|
||
}
|
||
|
||
// «Исполнено» — в тех же разделах, что и «Исполнитель»: там заявку смотрят со
|
||
// стороны, и важно, как далеко продвинулось исполнение. На «Инициированных» с
|
||
// фильтром по статусу — только при статусах, где выполнившие уже бывают.
|
||
const STATUSES_WITH_EXECUTED: string[] = [REQUEST_STATUS.execution, REQUEST_STATUS.closed];
|
||
|
||
function showsExecuted(view: ListView, statusFilter: string): boolean {
|
||
if (ASSIGNEE_VIEWS.indexOf(view) < 0) return false;
|
||
if (view === 'own:created' && statusFilter) return STATUSES_WITH_EXECUTED.indexOf(statusFilter) >= 0;
|
||
return true;
|
||
}
|
||
|
||
/** Дата выхода сотрудника есть только у заявки на организацию рабочего места, и показываем её во «Все заявки». */
|
||
function showsStartDate(view: ListView, type: IRequestType | undefined): boolean {
|
||
return view === 'all' && type?.pageUrl === WORKPLACE_SETUP_FORM_ID;
|
||
}
|
||
|
||
function columnsFor(view: ListView, statusFilter: string, startDate = false): ITableColumns {
|
||
const assignee = showsAssignee(view, statusFilter);
|
||
const executed = showsExecuted(view, statusFilter);
|
||
const decisionStatus = decisionStatusFor(view, statusFilter);
|
||
const commentLabel =
|
||
decisionStatus === REQUEST_STATUS.rejected
|
||
? 'Причина отклонения'
|
||
: decisionStatus === REQUEST_STATUS.returned
|
||
? 'Причина возврата'
|
||
: undefined;
|
||
|
||
if (view === 'approvals' || view === 'execution') return { author: true, status: false, assignee, executed };
|
||
if (view === 'rework') return { author: false, status: false, assignee, executed, commentLabel };
|
||
if (view === 'own:created') return { author: false, status: !statusFilter, assignee, executed, commentLabel };
|
||
// 'all' — заявки всех авторов вперемешку, поэтому нужны и автор, и статус.
|
||
if (view === 'all') return { author: true, status: true, assignee, executed, startDate };
|
||
// История решений: заявка чужая, поэтому нужен автор. Статус — только если он
|
||
// не предопределён самой вкладкой («Отклонённые мной» — это всегда «Отклонено»,
|
||
// а вот у согласованных/выполненных заявка могла уйти дальше по маршруту).
|
||
return { author: true, status: !decisionStatus, assignee, executed, commentLabel };
|
||
}
|
||
|
||
export interface IRequestsTableViewProps {
|
||
context: WebPartContext;
|
||
view: ListView;
|
||
/** Фильтр по статусу заявки; только для view='own:created'. '' — все статусы. */
|
||
statusFilter?: string;
|
||
onStatusFilterChange?: (status: string) => void;
|
||
/**
|
||
* Текущая страница (1-based) и её смена. Страница живёт в маршруте, а не
|
||
* внутри компонента: так она попадает в адрес (ссылку можно передать),
|
||
* переживает открытие/закрытие заявки и запоминается для этой таблицы
|
||
* (см. persistPage в shared/routing.ts).
|
||
*/
|
||
page: number;
|
||
onPageChange: (page: number) => void;
|
||
/**
|
||
* Текст поиска и его смена. Тоже живёт в маршруте (см. page выше): попадает
|
||
* в адрес, переживает открытие заявки и запоминается для этой таблицы.
|
||
*/
|
||
query: string;
|
||
onQueryChange: (query: string) => void;
|
||
/** Клик по строке — открыть заявку (в выезжающей панели). */
|
||
onOpen: (request: IMyRequest) => void;
|
||
/** Только при view='all' — тип заявки, чьи заявки показываются целиком (без фильтра по автору). */
|
||
allRequestsType?: IRequestType;
|
||
}
|
||
|
||
function 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()}`;
|
||
}
|
||
|
||
/** Человек в ячейке — «ФИО · почта»; колонки «Автор» и «Исполнитель». */
|
||
function personLabel(name: string, email: string): string {
|
||
return [name, email].filter(Boolean).join(' · ') || '—';
|
||
}
|
||
|
||
// Таблица заявок. Клик в ЛЮБОМ месте строки открывает заявку (кликабельна вся
|
||
// строка, а не только название), поэтому строка — <tr> с onClick + tabIndex
|
||
// (доступ с клавиатуры: Enter/Space).
|
||
//
|
||
// Набор колонок зависит от раздела и от фильтра — см. columnsFor.
|
||
/**
|
||
* Можно ли удалить строку прямо из таблицы: своя заявка в статусе «Черновик»
|
||
* и форма этого типа есть в портале (чужие типы из approval-route портал не
|
||
* открывает и не удаляет).
|
||
*/
|
||
function canDeleteRow(r: IMyRequest): boolean {
|
||
return r.kind === 'own' && isDeletableStatus(r.status) && !!getFormComponent(r.formKey);
|
||
}
|
||
|
||
const Table: React.FC<{
|
||
rows: IMyRequest[];
|
||
view: ListView;
|
||
statusFilter: string;
|
||
/** Показывать колонку «Дата выхода» (см. showsStartDate). */
|
||
withStartDate: boolean;
|
||
sort: ISortState;
|
||
onSort: (key: SortKey) => void;
|
||
onOpen: (request: IMyRequest) => void;
|
||
/** Показать историю согласования заявки (окно, без открытия заявки). */
|
||
onShowHistory: (request: IMyRequest) => void;
|
||
/** Удалить черновик; задан только там, где в таблице бывают свои черновики. */
|
||
onDelete?: (request: IMyRequest) => void;
|
||
}> = ({ rows, view, statusFilter, withStartDate, sort, onSort, onOpen, onShowHistory, onDelete }) => {
|
||
const cols = columnsFor(view, statusFilter, withStartDate);
|
||
const sortable = (label: string, key: SortKey): React.ReactNode => (
|
||
<SortableHeader label={label} sortKey={key} sort={sort} onSort={onSort} />
|
||
);
|
||
|
||
// Колонки для DataTable строим динамически: базовые всегда, автор/статус/
|
||
// комментарий — по columnsFor (зависит от раздела и фильтра). Кастомные ячейки
|
||
// (статус → StatusBadge, дата → формат) задаются через render.
|
||
const columns: IDataTableColumn<IMyRequest>[] = [
|
||
// Код заявки, а не Id элемента: Id уникален только внутри своей партиции
|
||
// (в каждом списке SharePoint нумерация с единицы), поэтому после архивации
|
||
// в таблице встречались разные заявки под одним номером. Кода нет у заявок,
|
||
// созданных до появления колонки — для них показываем Id, как раньше.
|
||
{ key: 'code', title: sortable('Номер', 'code'), width: '130px', render: (r) => r.code || `#${r.id}` },
|
||
{ key: 'type', title: sortable('Тип', 'type'), width: '200px', render: (r) => r.requestTypeName },
|
||
{ key: 'title', title: 'Название', render: (r) => r.title || r.code || `#${r.id}` },
|
||
{ key: 'date', title: sortable('Дата', 'date'), width: '110px', render: (r) => formatDate(r.created) }
|
||
];
|
||
if (cols.startDate) {
|
||
columns.push({
|
||
key: 'startDate',
|
||
title: 'Дата выхода',
|
||
width: '120px',
|
||
render: (r) => formatDate(r.startDate ?? '') || '—'
|
||
});
|
||
}
|
||
if (cols.author) {
|
||
columns.push({
|
||
key: 'author',
|
||
title: 'Автор',
|
||
width: '280px',
|
||
render: (r) => personLabel(r.authorName, r.authorEmail)
|
||
});
|
||
}
|
||
if (cols.status) {
|
||
columns.push({
|
||
key: 'status',
|
||
title: sortable('Статус', 'status'),
|
||
width: '190px',
|
||
render: (r) => (r.status ? <StatusBadge status={r.status} size="sm" /> : '—')
|
||
});
|
||
}
|
||
if (cols.assignee) {
|
||
columns.push({
|
||
key: 'assignee',
|
||
title: 'Исполнитель',
|
||
// Та же ширина, что у «Автора»: содержимое того же вида.
|
||
width: '280px',
|
||
// «ФИО · почта», как в колонке «Автор». Параллельный шаг — каждый
|
||
// человек отдельной строкой.
|
||
render: (r) =>
|
||
r.currentAssignees && r.currentAssignees.length > 0
|
||
? r.currentAssignees.map((a) => (
|
||
<div key={`${a.email}|${a.name}`}>{personLabel(a.name, a.email)}</div>
|
||
))
|
||
: '—'
|
||
});
|
||
}
|
||
// Только если на странице есть хоть одна выполненная часть: у типов заявок
|
||
// без фазы исполнения (и на страницах, где до неё ещё не дошло) колонка
|
||
// вышла бы из одних прочерков.
|
||
if (cols.executed && rows.some((r) => (r.executedSteps ?? []).length > 0)) {
|
||
columns.push({
|
||
key: 'executed',
|
||
title: 'Исполнено',
|
||
width: '280px',
|
||
// Каждый выполнивший — отдельной строкой: «ФИО · дата время».
|
||
render: (r) =>
|
||
r.executedSteps && r.executedSteps.length > 0
|
||
? r.executedSteps.map((s) => (
|
||
<div key={`${s.email}|${s.name}|${s.executedAt}`}>
|
||
{[s.name || s.email, s.executedAt ? formatDateTime(s.executedAt) : ''].filter(Boolean).join(' · ')}
|
||
</div>
|
||
))
|
||
: '—'
|
||
});
|
||
}
|
||
if (cols.commentLabel) {
|
||
columns.push({
|
||
key: 'comment',
|
||
title: cols.commentLabel,
|
||
render: (r) => r.decisionComment || <span className={styles.commentEmpty}>Без комментария</span>
|
||
});
|
||
}
|
||
// Действия строки: история согласования — у всех, корзина — только у своих
|
||
// черновиков. Клик по всей строке открывает заявку — кнопкам это не нужно,
|
||
// поэтому stopPropagation.
|
||
const hasDeletable = !!onDelete && rows.some(canDeleteRow);
|
||
columns.push({
|
||
key: 'actions',
|
||
title: '',
|
||
// Место под одну или две иконки-кнопки (sm) с зазором.
|
||
width: hasDeletable ? '92px' : '56px',
|
||
render: (r) => (
|
||
<div className={styles.rowActions}>
|
||
<IconButton
|
||
icon="calendar"
|
||
title="История согласования"
|
||
size="sm"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onShowHistory(r);
|
||
}}
|
||
/>
|
||
{onDelete && canDeleteRow(r) && (
|
||
<IconButton
|
||
icon="trash"
|
||
title="Удалить черновик"
|
||
danger
|
||
size="sm"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onDelete(r);
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
});
|
||
|
||
return (
|
||
<div className={styles.tableWrap}>
|
||
<DataTable
|
||
columns={columns}
|
||
rows={rows}
|
||
rowKey={(r) => `${r.requestTypeId}-${r.id}`}
|
||
onRowClick={onOpen}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Раздел с таблицей заявок: поиск по всем полям + вывод постранично.
|
||
//
|
||
// Движок выбирается по наличию текста в поиске, а не по разделу: БЕЗ запроса —
|
||
// всегда обычный REST (мгновенно свежий, без задержки индексации), С запросом —
|
||
// всегда Search (полнотекстовый, но переиндексируется с задержкой). Раньше
|
||
// поиск подмешивал REST для «свежих» строк поверх Search — из-за этого
|
||
// результат мог быть неустойчивым (заявка то находилась, то нет, в зависимости
|
||
// от того, попала ли она в окно подстраховки); эту гибридную логику убрали.
|
||
//
|
||
// 'own:created' без фильтра и поиска — getOwnRequests (Search + REST-довесок на
|
||
// свежесть). С поиском — searchOwnRequests (Search, RowLimit/StartRow).
|
||
//
|
||
// Очереди и история решений ('approvals'/'execution'/'own:*' кроме created) без
|
||
// поиска — getApprovalQueue: REST по ApproverId(+Status), страница нарезается на
|
||
// СЕРВЕРЕ (сам берёт нужный срез индекса и дочитывает автора только для него).
|
||
// С поиском — getApprovalQueueIndex даёт ПОЛНЫЙ список этой же очереди без
|
||
// автора (постранично его тут не взять, т.к. фильтрация текстом идёт уже
|
||
// после), затем текстовый фильтр через Search (filterPendingByQuery), и только
|
||
// после фильтрации — нарезка на страницы и дочитывание автора
|
||
// (hydratePendingAuthors) — тоже только для видимой страницы.
|
||
//
|
||
// Отбор по статусу ЗАЯВКИ ('rework' и фильтр на 'own:created') — НЕ через KQL:
|
||
// managed property для произвольной Choice-колонки ненадёжна (см.
|
||
// getOwnRequestsByStatus в myRequests.service.ts — там объяснение и ссылка на
|
||
// реальный инцидент). Вместо этого Search ищет только по автору (надёжная
|
||
// AuthorOWSUSER), а статус фильтруется в памяти по полям, дочитанным через
|
||
// REST; постраничность там же, в памяти.
|
||
//
|
||
// Все варианты перечитываются по событию rp-requests-changed
|
||
// (shared/requestEvents) — после создания заявки и после решения согласующего.
|
||
// Поиск идёт по индексу, поэтому свежая правка может проявиться не мгновенно.
|
||
export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||
context,
|
||
view,
|
||
statusFilter = '',
|
||
onStatusFilterChange,
|
||
page,
|
||
onPageChange,
|
||
query,
|
||
onQueryChange,
|
||
onOpen,
|
||
allRequestsType,
|
||
}) => {
|
||
const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
|
||
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
|
||
const [totalRows, setTotalRows] = React.useState<number>(0);
|
||
const [error, setError] = React.useState<string>('');
|
||
const [deleteError, setDeleteError] = React.useState<string>('');
|
||
// Заявка, чья история согласования открыта в окне; undefined — окно закрыто.
|
||
const [historyFor, setHistoryFor] = React.useState<IMyRequest | undefined>(undefined);
|
||
|
||
// Список перечитается сам: confirmAndDeleteDraft шлёт rp-requests-changed.
|
||
const handleDelete = (request: IMyRequest): void => {
|
||
setDeleteError('');
|
||
confirmAndDeleteDraft({
|
||
formKey: request.formKey,
|
||
itemId: request.id,
|
||
listId: request.listId,
|
||
code: request.code
|
||
}).catch((e) => setDeleteError(`Не удалось удалить черновик: ${friendlyErrorMessage(e)}`));
|
||
};
|
||
|
||
// input — то, что в поле ввода (меняется сразу); в маршрут значение уходит
|
||
// после паузы в наборе. Данные грузятся по МАРШРУТНОМУ query — он и так уже
|
||
// «отложенный», отдельный debounce здесь был бы вторым по счёту.
|
||
const [input, setInput] = useRoutedQuery(query, onQueryChange);
|
||
const debouncedQuery = query;
|
||
|
||
// Уточнён ли список (refinement.isRefined) сверх того, что умеет обычная
|
||
// постраничная загрузка. Если да — таблица грузит ПОЛНЫЙ список раздела и сама
|
||
// фильтрует, сортирует и режет его на страницы (см. fetchRefinedPage). Если
|
||
// нет — всё как раньше.
|
||
const refinement = useTableRefinement({
|
||
tableKey: `${view}|${allRequestsType?.id ?? ''}`,
|
||
storageScope: effectiveUserEmail(context),
|
||
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);
|
||
// Сколько заявок в разделе БЕЗ фильтров — для «Показано 3 из 10» в строке
|
||
// включённых фильтров. undefined — неизвестно (постраничная загрузка или
|
||
// статус из маршрута уже сузил список на сервере): тогда пишем просто «Показано 3».
|
||
const [unfilteredTotal, setUnfilteredTotal] = React.useState<number | undefined>(undefined);
|
||
|
||
// Какой ИМЕННО таблице принадлежат лежащие в состоянии rows/totalRows.
|
||
// Компонент один на все разделы (см. LIST_VIEWS в RequestPortal), поэтому при
|
||
// переключении раздела его состояние ещё хранит данные ПРЕДЫДУЩЕГО, пока не
|
||
// отработает загрузка. Без этой отметки сброс страницы ниже принимал решение
|
||
// по чужому totalRows и сбивал страницу на первую — из-за чего запомненная
|
||
// страница затиралась при каждом переходе по левому меню.
|
||
// Страница в ключ НЕ входит: totalRows один на всю таблицу, а не на страницу.
|
||
const dataKey = `${view}|${statusFilter}|${debouncedQuery.trim()}|${allRequestsType?.id ?? ''}|${refinementSig}`;
|
||
const [loadedKey, setLoadedKey] = React.useState<string>('');
|
||
|
||
// Страницу при смене запроса здесь НЕ сбрасываем: и запрос, и страница живут
|
||
// в маршруте, и меняются они одним переходом (см. setQuery в RequestPortal).
|
||
|
||
React.useEffect(() => {
|
||
let cancelled = false;
|
||
getLinkedTypes(context)
|
||
.then((t) => {
|
||
if (!cancelled) setTypes(t);
|
||
})
|
||
.catch((e) => {
|
||
if (!cancelled) setError((e as Error).message);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [context]);
|
||
|
||
React.useEffect(() => {
|
||
if (types === undefined) return;
|
||
let cancelled = false;
|
||
const cacheKey = pageKey('table', [
|
||
view,
|
||
statusFilter,
|
||
debouncedQuery.trim(),
|
||
page,
|
||
allRequestsType?.id ?? 0,
|
||
refinementSig,
|
||
]);
|
||
|
||
// Сразу показываем то, что уже загружали для ЭТОЙ вкладки (и этого запроса
|
||
// и страницы), а обновление идёт фоном. Компонент один на обе вкладки —
|
||
// без кеша при переключении на экране оставались бы строки предыдущей
|
||
// вкладки, пока не придёт ответ. Если для этой вкладки данных ещё нет —
|
||
// rows становится undefined, и показывается «Загрузка…».
|
||
const cached = readPage(cacheKey);
|
||
setRows(cached ? cached.rows : undefined);
|
||
setTotalRows(cached ? cached.totalRows : 0);
|
||
// Пока не подтвердим, что загруженное относится к ТЕКУЩЕЙ таблице, ничего
|
||
// на основании totalRows решать нельзя — см. loadedKey ниже.
|
||
setLoadedKey(cached ? dataKey : '');
|
||
|
||
const q = debouncedQuery.trim();
|
||
|
||
// onPartial — Search и REST-довесок по свежести («Мои заявки»/«Доработка»,
|
||
// см. getOwnRequests/getOwnRequestsByStatus в myRequests.service.ts) теперь
|
||
// идут параллельно, и заранее не угадать, какой из двух ответит первым —
|
||
// сеть шумит то в одну, то в другую сторону. onPartial зовётся с тем, что
|
||
// пришло раньше, чтобы не держать список пустым ради заведомо более
|
||
// медленного источника. Остальные ветки (approvals/execution/поиск) его
|
||
// просто не принимают — там нет пары «Search + довесок» с независимо
|
||
// валидным промежуточным результатом.
|
||
// ПОЛНЫЙ список раздела (с учётом поиска и статуса из маршрута), без нарезки
|
||
// на страницы — сырьё для фильтров и сортировки. Источники те же, что у
|
||
// постраничной загрузки ниже, только без 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)) {
|
||
const status = view === 'rework' ? REQUEST_STATUS.returned : statusFilter;
|
||
return getOwnRequestsByStatus(context, types, status, page, debouncedQuery, onPartial);
|
||
}
|
||
|
||
if (view === 'own:created') {
|
||
return q.length > 0
|
||
? searchOwnRequests(context, types, debouncedQuery, page)
|
||
: getOwnRequests(context, types, page, onPartial);
|
||
}
|
||
|
||
if (view === 'all') {
|
||
if (!allRequestsType) return { rows: [], totalRows: 0 };
|
||
return getAllRequestsForType(context, allRequestsType, page, debouncedQuery);
|
||
}
|
||
|
||
const queue = QUEUE_BY_VIEW[view];
|
||
if (q.length === 0) {
|
||
// Без поиска — страница уже нарезана и дочитана сервером.
|
||
const r = await getApprovalQueue(context, queue, page);
|
||
return { rows: r.rows, totalRows: r.totalRows };
|
||
}
|
||
|
||
// С поиском — сначала полный список этой очереди (без автора), фильтруем
|
||
// текстом, и только потом режем на страницы и дочитываем автора — только
|
||
// для видимой страницы.
|
||
const idx = await getApprovalQueueIndex(context, queue);
|
||
const filtered = await filterPendingByQuery(context, idx.types, idx.rows, debouncedQuery);
|
||
const pageRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||
await hydratePendingAuthors(context, idx.types, pageRows);
|
||
return { rows: pageRows, totalRows: filtered.length };
|
||
};
|
||
|
||
// Комментарий к решению дочитываем ОДНИМ местом на все ветки: «Доработка» и
|
||
// «Отклонённые мной» приходят разными путями (по автору и по строкам
|
||
// маршрута соответственно), а колонка у них одинаковая по смыслу. И только
|
||
// для видимой страницы — как и автор в hydratePendingAuthors. Промежуточный
|
||
// показ (onPartial) комментарий ещё не несёт — он дочитывается только для
|
||
// ИТОГОВОГО результата, полный показ его донесёт следом.
|
||
const fetchPage = async (onPartial?: (p: IPage) => void, reuseFull = false): Promise<IPage> => {
|
||
const result = await fetchRows(onPartial, reuseFull);
|
||
const decisionStatus = decisionStatusFor(view, statusFilter);
|
||
// Оба дочитывания из строк маршрута независимы — параллельно.
|
||
await Promise.all([
|
||
decisionStatus ? hydrateDecisionComments(context, types, result.rows, decisionStatus) : undefined,
|
||
showsAssignee(view, statusFilter) ? hydrateCurrentAssignees(context, types, result.rows) : undefined,
|
||
showsExecuted(view, statusFilter) ? hydrateExecutedSteps(context, types, result.rows) : undefined,
|
||
showsStartDate(view, allRequestsType) ? hydrateStartDates(context, result.rows) : undefined,
|
||
]);
|
||
return result;
|
||
};
|
||
|
||
// reuseFull — только для первой загрузки эффекта (см. fetchRefinedPage):
|
||
// автообновление и rp-requests-changed обязаны читать данные заново.
|
||
const load = async (forceRender = false, reuseFull = false): Promise<void> => {
|
||
try {
|
||
// Промежуточный показ — ТОЛЬКО когда для этой страницы прямо СЕЙЧАС
|
||
// ничего не закешировано (первый заход, экран пуст). Проверяем на
|
||
// каждый вызов load(), а не один раз на весь эффект: тот же load()
|
||
// переиспользуется автообновлением по таймеру и событием
|
||
// rp-requests-changed все 10 минут жизни вкладки, и ко второму вызову
|
||
// кеш почти наверняка уже есть — без повторной проверки список бы на
|
||
// мгновение "усыхал" при КАЖДОМ фоновом обновлении, а не только при
|
||
// самой первой загрузке.
|
||
const onPartial = readPage(cacheKey)
|
||
? undefined
|
||
: (partial: IPage): void => {
|
||
if (cancelled) return;
|
||
// ПУСТОЙ промежуточный результат не показываем: смысл onPartial —
|
||
// не держать экран пустым, пока отвечает более медленный источник,
|
||
// а пустая страница как раз пустой экран и даёт, только вместо
|
||
// «Загрузка…» на нём появляется «Вы ещё не создавали заявок» —
|
||
// и через мгновение исчезает, когда придёт второй источник.
|
||
if (partial.rows.length === 0) return;
|
||
setRows(partial.rows);
|
||
setTotalRows(partial.totalRows);
|
||
};
|
||
|
||
const fresh = await perfScope(`таблица ${view} стр.${page}`, () => fetchPage(onPartial, reuseFull));
|
||
if (cancelled) return;
|
||
|
||
// Перерисовываем, только если данные реально изменились — иначе при
|
||
// каждом возврате на вкладку таблица моргала бы, перерисовываясь тем же
|
||
// самым содержимым. forceRender — исключение: строки уже спрятаны со
|
||
// экрана (см. force ниже), и их обязательно нужно вернуть, даже если
|
||
// свежий ответ побайтово совпал с тем, что было в кеше, — иначе таблица
|
||
// так и останется в состоянии «Загрузка…», хотя данные давно пришли
|
||
// (баг: ушёл на этом самом совпадении сигнатур, найден 2026-08-18).
|
||
const prev = readPage(cacheKey);
|
||
if (forceRender || !prev || pageSignature(prev) !== pageSignature(fresh)) {
|
||
writePage(cacheKey, fresh);
|
||
setRows(fresh.rows);
|
||
setTotalRows(fresh.totalRows);
|
||
}
|
||
const full = fullListRef.current;
|
||
const narrowedByRouteStatus = view === 'own:created' && !!statusFilter;
|
||
setUnfilteredTotal(
|
||
isRefined && !narrowedByRouteStatus && full && full.key === fullKey ? full.rows.length : undefined
|
||
);
|
||
// Данные точно от текущей таблицы — теперь по ним можно судить о
|
||
// количестве страниц (см. сброс страницы ниже).
|
||
setLoadedKey(dataKey);
|
||
setError('');
|
||
} catch (e) {
|
||
if (!cancelled) setError((e as Error).message);
|
||
}
|
||
};
|
||
|
||
load(false, true).catch(() => undefined); // ошибки уже обработаны внутри load через setError
|
||
const unsubscribe = onRequestsChanged((force) => {
|
||
// Явный повторный клик по уже открытому пункту меню — пользователь ждёт
|
||
// видимого обновления, а не молчаливой подмены данных за спиной. Прежние
|
||
// строки прячем сразу; свежие подставит load(force).
|
||
if (force) {
|
||
setRows(undefined);
|
||
setLoadedKey('');
|
||
}
|
||
load(force).catch(() => undefined);
|
||
});
|
||
// Пока вкладка браузера скрыта, обновляться незачем — при возврате к ней
|
||
// сработает ближайший тик.
|
||
const timer = window.setInterval(() => {
|
||
if (!document.hidden) load().catch(() => undefined);
|
||
}, AUTO_REFRESH_MS);
|
||
return () => {
|
||
cancelled = true;
|
||
unsubscribe();
|
||
window.clearInterval(timer);
|
||
};
|
||
// 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 total = totalRows;
|
||
const visible = rows || [];
|
||
|
||
/**
|
||
* Можно ли уже судить о содержимом таблицы.
|
||
*
|
||
* Пустоту («Вы ещё не создавали заявок») объявляем ТОЛЬКО по завершённой
|
||
* загрузке именно ЭТОЙ таблицы (loadedKey), а не по любому пустому rows: до
|
||
* этого rows может быть пустым по совершенно другой причине — часть источников
|
||
* ещё в пути (см. onPartial выше, «Мои заявки» собираются из Search + REST).
|
||
* Промежуточный НЕПУСТОЙ результат показывать можно и нужно — в нём уже есть
|
||
* что смотреть.
|
||
*/
|
||
const loaded = loadedKey === dataKey || visible.length > 0;
|
||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||
|
||
// Страница может оказаться за пределами набора: пришла из старой ссылки или
|
||
// из запомненной, а заявок с тех пор стало меньше (часть согласовали, и они
|
||
// ушли из этой очереди). Показывать пустую таблицу с непустым счётчиком —
|
||
// выглядит как поломка, поэтому возвращаемся на последнюю существующую.
|
||
//
|
||
// Строго ПОСЛЕ проверки loadedKey: totalRows в состоянии может быть ещё от
|
||
// предыдущей таблицы (компонент общий), и решение по нему сбило бы страницу
|
||
// на первую при обычном переходе по левому меню.
|
||
React.useEffect(() => {
|
||
if (loadedKey !== dataKey) return;
|
||
if (rows && page > totalPages) onPageChange(totalPages);
|
||
}, [loadedKey, dataKey, rows, page, totalPages, onPageChange]);
|
||
|
||
// Запомненный тип заявки, которого больше нет среди доступных пользователю
|
||
// (тип убрали или доступ к нему отозвали), — снимаем: иначе таблица навсегда
|
||
// осталась бы пустой по фильтру, который в выпадающем списке даже не выбрать.
|
||
const storedTypeId = refinement.barFilters.typeId;
|
||
React.useEffect(() => {
|
||
if (view === 'all' || !types || storedTypeId === undefined) return;
|
||
if (types.some((t) => t.id === storedTypeId)) return;
|
||
refinement.onFiltersChange({ ...refinement.barFilters, typeId: undefined });
|
||
// refinement пересобирается на каждый рендер — реагируем только на сам тип и справочник.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [view, types, storedTypeId]);
|
||
|
||
return (
|
||
<section className={styles.view}>
|
||
<div className={styles.head}>
|
||
<h2 className={styles.heading}>
|
||
{view === 'all' && allRequestsType ? `Все заявки: ${allRequestsType.name}` : HEADINGS[view]}
|
||
{rows && <span className={styles.count}>{total}</span>}
|
||
</h2>
|
||
<input
|
||
type="search"
|
||
className={styles.search}
|
||
placeholder="Поиск по заявкам…"
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<RequestsFilterBar
|
||
filters={refinement.barFilters}
|
||
onChange={refinement.onFiltersChange}
|
||
// «Все заявки» уже показывают один выбранный тип — фильтр по типу там пуст.
|
||
types={view === 'all' ? undefined : types}
|
||
// Статус — там же, где его колонка: в остальных разделах он у всех строк один.
|
||
showStatus={columnsFor(view, '').status}
|
||
// При включённых фильтрах сброс — в строке ActiveFiltersBar («Сбросить все»);
|
||
// здесь кнопка остаётся только для случая «изменена одна сортировка».
|
||
canReset={refinement.canReset && !hasActiveFilters(refinement.barFilters)}
|
||
onReset={refinement.onReset}
|
||
/>
|
||
|
||
<ActiveFiltersBar
|
||
filters={refinement.barFilters}
|
||
types={types}
|
||
onChange={refinement.onFiltersChange}
|
||
onReset={refinement.onReset}
|
||
shown={loaded ? total : undefined}
|
||
total={unfilteredTotal}
|
||
/>
|
||
|
||
{error && <div className={styles.error}>Не удалось загрузить заявки: {error}</div>}
|
||
{deleteError && <div className={styles.error}>{deleteError}</div>}
|
||
{!error && !loaded && <div className={styles.empty}>Загрузка…</div>}
|
||
{!error && loaded && total === 0 && (
|
||
<div className={styles.empty}>
|
||
{q
|
||
? `По запросу «${debouncedQuery}» ничего не найдено.`
|
||
: hasActiveFilters(refinement.barFilters)
|
||
? NO_MATCHES_TEXT
|
||
: EMPTY_TEXTS[view]}
|
||
</div>
|
||
)}
|
||
{!error && rows && total > 0 && (
|
||
<>
|
||
<Table
|
||
rows={visible}
|
||
view={view}
|
||
statusFilter={statusFilter}
|
||
withStartDate={showsStartDate(view, allRequestsType)}
|
||
sort={sort}
|
||
onSort={refinement.onSort}
|
||
onOpen={onOpen}
|
||
onShowHistory={setHistoryFor}
|
||
onDelete={view === 'own:created' ? handleDelete : undefined}
|
||
/>
|
||
<Pager page={page} totalPages={totalPages} onChange={onPageChange} />
|
||
</>
|
||
)}
|
||
<RequestHistoryDialog request={historyFor} onClose={() => setHistoryFor(undefined)} />
|
||
</section>
|
||
);
|
||
};
|