feat: столбец «Исполнено» в таблицах заявок — кто и когда выполнил

Столбец «Исполнитель» показывает только текущего держателя, и было не видно,
кто из исполнителей уже отметил «Выполнено». Теперь видно ФИО и время
каждой отметки; столбец появляется, только если на странице есть такие.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-08 16:42:07 +05:00
parent 290598c357
commit ea07e8d5ab
5 changed files with 136 additions and 6 deletions
@@ -14,6 +14,7 @@ import {
getOwnRequestsByStatus,
hydrateCurrentAssignees,
hydrateDecisionComments,
hydrateExecutedSteps,
hydratePendingAuthors,
searchOwnRequests,
} from './myRequests.service';
@@ -37,6 +38,7 @@ 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;
@@ -139,6 +141,8 @@ interface ITableColumns {
status: boolean;
/** Колонка «Исполнитель» — у кого заявка сейчас (см. showsAssignee). */
assignee: boolean;
/** Колонка «Исполнено» — кто из исполнителей уже выполнил (см. showsExecuted). */
executed: boolean;
/** Колонка «Дата выхода» — только «Все заявки» по организации рабочего места. */
startDate?: boolean;
/** Заголовок колонки с комментарием; undefined — колонки нет. */
@@ -163,6 +167,17 @@ function showsAssignee(view: ListView, statusFilter: string): boolean {
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;
@@ -170,6 +185,7 @@ function showsStartDate(view: ListView, type: IRequestType | undefined): boolean
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
@@ -178,15 +194,15 @@ function columnsFor(view: ListView, statusFilter: string, startDate = false): IT
? 'Причина возврата'
: undefined;
if (view === 'approvals' || view === 'execution') return { author: true, status: false, assignee };
if (view === 'rework') return { author: false, status: false, assignee, commentLabel };
if (view === 'own:created') return { author: false, status: !statusFilter, assignee, commentLabel };
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, startDate };
if (view === 'all') return { author: true, status: true, assignee, executed, startDate };
// История решений: заявка чужая, поэтому нужен автор. Статус — только если он
// не предопределён самой вкладкой («Отклонённые мной» — это всегда «Отклонено»,
// а вот у согласованных/выполненных заявка могла уйти дальше по маршруту).
return { author: true, status: !decisionStatus, assignee, commentLabel };
return { author: true, status: !decisionStatus, assignee, executed, commentLabel };
}
export interface IRequestsTableViewProps {
@@ -314,6 +330,25 @@ const Table: React.FC<{
: '—'
});
}
// Только если на странице есть хоть одна выполненная часть: у типов заявок
// без фазы исполнения (и на страницах, где до неё ещё не дошло) колонка
// вышла бы из одних прочерков.
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',
@@ -614,6 +649,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
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;