feat: столбец «Исполнено» в таблицах заявок — кто и когда выполнил
Столбец «Исполнитель» показывает только текущего держателя, и было не видно, кто из исполнителей уже отметил «Выполнено». Теперь видно ФИО и время каждой отметки; столбец появляется, только если на странице есть такие. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
|||||||
getOwnRequestsByStatus,
|
getOwnRequestsByStatus,
|
||||||
hydrateCurrentAssignees,
|
hydrateCurrentAssignees,
|
||||||
hydrateDecisionComments,
|
hydrateDecisionComments,
|
||||||
|
hydrateExecutedSteps,
|
||||||
hydratePendingAuthors,
|
hydratePendingAuthors,
|
||||||
searchOwnRequests,
|
searchOwnRequests,
|
||||||
} from './myRequests.service';
|
} from './myRequests.service';
|
||||||
@@ -37,6 +38,7 @@ import { RequestHistoryDialog } from './RequestHistoryDialog';
|
|||||||
import { SortableHeader } from './SortableHeader';
|
import { SortableHeader } from './SortableHeader';
|
||||||
import { filterRows, hasActiveFilters, ISortState, SortKey, sortRows } from './requestsTableFilters';
|
import { filterRows, hasActiveFilters, ISortState, SortKey, sortRows } from './requestsTableFilters';
|
||||||
import { useTableRefinement } from './useTableRefinement';
|
import { useTableRefinement } from './useTableRefinement';
|
||||||
|
import { formatDateTime } from '../../shared/ui/FieldChanges/formatDateTime';
|
||||||
import styles from './RequestsTableView.module.scss';
|
import styles from './RequestsTableView.module.scss';
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
@@ -139,6 +141,8 @@ interface ITableColumns {
|
|||||||
status: boolean;
|
status: boolean;
|
||||||
/** Колонка «Исполнитель» — у кого заявка сейчас (см. showsAssignee). */
|
/** Колонка «Исполнитель» — у кого заявка сейчас (см. showsAssignee). */
|
||||||
assignee: boolean;
|
assignee: boolean;
|
||||||
|
/** Колонка «Исполнено» — кто из исполнителей уже выполнил (см. showsExecuted). */
|
||||||
|
executed: boolean;
|
||||||
/** Колонка «Дата выхода» — только «Все заявки» по организации рабочего места. */
|
/** Колонка «Дата выхода» — только «Все заявки» по организации рабочего места. */
|
||||||
startDate?: boolean;
|
startDate?: boolean;
|
||||||
/** Заголовок колонки с комментарием; undefined — колонки нет. */
|
/** Заголовок колонки с комментарием; undefined — колонки нет. */
|
||||||
@@ -163,6 +167,17 @@ function showsAssignee(view: ListView, statusFilter: string): boolean {
|
|||||||
return true;
|
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 {
|
function showsStartDate(view: ListView, type: IRequestType | undefined): boolean {
|
||||||
return view === 'all' && type?.pageUrl === WORKPLACE_SETUP_FORM_ID;
|
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 {
|
function columnsFor(view: ListView, statusFilter: string, startDate = false): ITableColumns {
|
||||||
const assignee = showsAssignee(view, statusFilter);
|
const assignee = showsAssignee(view, statusFilter);
|
||||||
|
const executed = showsExecuted(view, statusFilter);
|
||||||
const decisionStatus = decisionStatusFor(view, statusFilter);
|
const decisionStatus = decisionStatusFor(view, statusFilter);
|
||||||
const commentLabel =
|
const commentLabel =
|
||||||
decisionStatus === REQUEST_STATUS.rejected
|
decisionStatus === REQUEST_STATUS.rejected
|
||||||
@@ -178,15 +194,15 @@ function columnsFor(view: ListView, statusFilter: string, startDate = false): IT
|
|||||||
? 'Причина возврата'
|
? 'Причина возврата'
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (view === 'approvals' || view === 'execution') return { author: true, status: false, assignee };
|
if (view === 'approvals' || view === 'execution') return { author: true, status: false, assignee, executed };
|
||||||
if (view === 'rework') return { author: false, status: false, assignee, commentLabel };
|
if (view === 'rework') return { author: false, status: false, assignee, executed, commentLabel };
|
||||||
if (view === 'own:created') return { author: false, status: !statusFilter, assignee, commentLabel };
|
if (view === 'own:created') return { author: false, status: !statusFilter, assignee, executed, commentLabel };
|
||||||
// 'all' — заявки всех авторов вперемешку, поэтому нужны и автор, и статус.
|
// '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 {
|
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) {
|
if (cols.commentLabel) {
|
||||||
columns.push({
|
columns.push({
|
||||||
key: 'comment',
|
key: 'comment',
|
||||||
@@ -614,6 +649,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
decisionStatus ? hydrateDecisionComments(context, types, result.rows, decisionStatus) : undefined,
|
decisionStatus ? hydrateDecisionComments(context, types, result.rows, decisionStatus) : undefined,
|
||||||
showsAssignee(view, statusFilter) ? hydrateCurrentAssignees(context, types, result.rows) : 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,
|
showsStartDate(view, allRequestsType) ? hydrateStartDates(context, result.rows) : undefined,
|
||||||
]);
|
]);
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ export interface IAssignee {
|
|||||||
email: string;
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Исполнитель, отметивший свой шаг «Выполнено» (колонка «Исполнено»). */
|
||||||
|
export interface IExecutedStep {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
/** Когда отметил (ISO-строка, колонка Date строки маршрута). */
|
||||||
|
executedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Строка заявки пользователя для боковой панели «Мои заявки».
|
// Строка заявки пользователя для боковой панели «Мои заявки».
|
||||||
// Собирается из списка данных SharePoint конкретного типа заявки (см. myRequests.service.ts).
|
// Собирается из списка данных SharePoint конкретного типа заявки (см. myRequests.service.ts).
|
||||||
export interface IMyRequest {
|
export interface IMyRequest {
|
||||||
@@ -76,6 +84,12 @@ export interface IMyRequest {
|
|||||||
* hydrateCurrentAssignees).
|
* hydrateCurrentAssignees).
|
||||||
*/
|
*/
|
||||||
currentAssignees?: IAssignee[];
|
currentAssignees?: IAssignee[];
|
||||||
|
/**
|
||||||
|
* Исполнители, уже отметившие «Выполнено», по времени отметки (колонка
|
||||||
|
* «Исполнено»). Пустой массив — никто ещё не выполнил или у заявки нет фазы
|
||||||
|
* исполнения; undefined — не дочитывалось (см. hydrateExecutedSteps).
|
||||||
|
*/
|
||||||
|
executedSteps?: IExecutedStep[];
|
||||||
/**
|
/**
|
||||||
* Дата выхода сотрудника на работу (колонка StartDate) — только у заявок на
|
* Дата выхода сотрудника на работу (колонка StartDate) — только у заявок на
|
||||||
* организацию рабочего места, колонка «Дата выхода» во «Все заявки». Как и
|
* организацию рабочего места, колонка «Дата выхода» во «Все заявки». Как и
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
import { DATA_SITE_URL } from '../../shared/config';
|
import { DATA_SITE_URL } from '../../shared/config';
|
||||||
import { IAssignee, IMyRequest } from './models/IMyRequest';
|
import { IAssignee, IExecutedStep, IMyRequest } from './models/IMyRequest';
|
||||||
import { parseAuthorOwsUser } from './parseAuthorOwsUser';
|
import { parseAuthorOwsUser } from './parseAuthorOwsUser';
|
||||||
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
|
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
|
||||||
import { traceLinkedTypes, traceResultRows, traceSearchHits } from './searchTrace';
|
import { traceLinkedTypes, traceResultRows, traceSearchHits } from './searchTrace';
|
||||||
@@ -1795,6 +1795,76 @@ async function hydrateCurrentAssigneesInner(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Статусы ЗАЯВКИ, при которых кто-то из исполнителей уже мог отметить «Выполнено».
|
||||||
|
const WITH_EXECUTION_REQUEST_STATUSES: string[] = [REQUEST_STATUS.execution, REQUEST_STATUS.closed];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проставляет executedSteps — кто из исполнителей уже отметил «Выполнено» и
|
||||||
|
* когда, по времени отметки. Статус строки «Выполнено» бывает только в фазе
|
||||||
|
* исполнения, поэтому фильтра по статусу достаточно.
|
||||||
|
*
|
||||||
|
* Только для видимой страницы, как и hydrateCurrentAssignees. Мутирует
|
||||||
|
* переданные строки.
|
||||||
|
*/
|
||||||
|
export async function hydrateExecutedSteps(
|
||||||
|
context: WebPartContext,
|
||||||
|
types: IRequestType[],
|
||||||
|
rows: IMyRequest[]
|
||||||
|
): Promise<void> {
|
||||||
|
rows.forEach((r) => {
|
||||||
|
r.executedSteps = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const withExecution = rows.filter((r) => WITH_EXECUTION_REQUEST_STATUSES.indexOf(r.status) >= 0);
|
||||||
|
if (withExecution.length === 0) return;
|
||||||
|
return traced('выполнившие исполнители (REST)', () => hydrateExecutedStepsInner(context, types, withExecution));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydrateExecutedStepsInner(
|
||||||
|
context: WebPartContext,
|
||||||
|
types: IRequestType[],
|
||||||
|
rows: IMyRequest[]
|
||||||
|
): Promise<void> {
|
||||||
|
const batches = await approvalPartitionsFor(context, types, rows);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
batches.map(async ({ approvalList, target, typeName, rows: partitionRows, ticketFilter }) => {
|
||||||
|
try {
|
||||||
|
const stepRows = (await approvalList.items
|
||||||
|
.filter(`${ticketFilter} and ApprovalStatus eq '${REQUEST_STATUS.executed}'`)
|
||||||
|
.select('TicketId', 'Date', `${STEP_APPROVER_FIELD}/Title`, `${STEP_APPROVER_FIELD}/EMail`)
|
||||||
|
.expand(STEP_APPROVER_FIELD)
|
||||||
|
.orderBy('Date', true)
|
||||||
|
.top(PAGE_ROUTE_ROW_LIMIT)()) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
const stepsByTicket = new Map<number, IExecutedStep[]>();
|
||||||
|
stepRows.forEach((r) => {
|
||||||
|
const ticketId = r.TicketId as number | undefined;
|
||||||
|
const user = r[STEP_APPROVER_FIELD] as { Title?: string; EMail?: string } | undefined;
|
||||||
|
const step: IExecutedStep = {
|
||||||
|
name: (user?.Title || '').trim(),
|
||||||
|
email: (user?.EMail || '').trim(),
|
||||||
|
executedAt: (r.Date as string) || '',
|
||||||
|
};
|
||||||
|
if (!ticketId || (!step.name && !step.email)) return;
|
||||||
|
const steps = stepsByTicket.get(ticketId) ?? [];
|
||||||
|
steps.push(step);
|
||||||
|
stepsByTicket.set(ticketId, steps);
|
||||||
|
});
|
||||||
|
|
||||||
|
partitionRows.forEach((r) => {
|
||||||
|
r.executedSteps = stepsByTicket.get(r.id) ?? [];
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`[Текущие заявки] «${typeName}» (${target.listId}): не удалось прочитать выполнивших исполнителей:`,
|
||||||
|
(e as Error).message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// «На согласовании»: список заявок уже отобран по строкам согласования
|
// «На согласовании»: список заявок уже отобран по строкам согласования
|
||||||
// (getPendingApprovalIndex — там же определяется, чья сейчас очередь,
|
// (getPendingApprovalIndex — там же определяется, чья сейчас очередь,
|
||||||
// обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search
|
// обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search
|
||||||
|
|||||||
@@ -130,6 +130,15 @@ describe('pageSignature', () => {
|
|||||||
expect(pageSignature(before)).not.toBe(pageSignature(after));
|
expect(pageSignature(before)).not.toBe(pageSignature(after));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('новый выполнивший меняет подпись — он показан колонкой «Исполнено»', () => {
|
||||||
|
// Один из параллельных исполнителей отметил «Выполнено»: статус заявки тот же («Выполнение»).
|
||||||
|
const before = page({ rows: [request({ executedSteps: [] })] });
|
||||||
|
const after = page({
|
||||||
|
rows: [request({ executedSteps: [{ name: 'Петров Пётр', email: 'petrov@tnsu.kz', executedAt: '2026-10-08T05:00:00Z' }] })],
|
||||||
|
});
|
||||||
|
expect(pageSignature(before)).not.toBe(pageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
it('пустая страница имеет стабильную подпись', () => {
|
it('пустая страница имеет стабильную подпись', () => {
|
||||||
expect(pageSignature({ rows: [], totalRows: 0 })).toBe(pageSignature({ rows: [], totalRows: 0 }));
|
expect(pageSignature({ rows: [], totalRows: 0 })).toBe(pageSignature({ rows: [], totalRows: 0 }));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export function pageSignature(page: IPage): string {
|
|||||||
r.decisionComment ?? '',
|
r.decisionComment ?? '',
|
||||||
r.startDate ?? '',
|
r.startDate ?? '',
|
||||||
(r.currentAssignees ?? []).map((a) => `${a.name}/${a.email}`).join(','),
|
(r.currentAssignees ?? []).map((a) => `${a.name}/${a.email}`).join(','),
|
||||||
|
(r.executedSteps ?? []).map((s) => `${s.name}/${s.email}/${s.executedAt}`).join(','),
|
||||||
].join('~')
|
].join('~')
|
||||||
)
|
)
|
||||||
.join(';')}`;
|
.join(';')}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user