From 10f70609adc2a0fd383654bdaa735b9540a92bd9 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 14 Aug 2026 12:36:42 +0500 Subject: [PATCH] =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=BB=D0=B8=D1=87=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=B4=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../approvalAdmin/components/app/AdminApp.tsx | 13 +- .../screens/RouteStepsScreen.module.scss | 56 ++++ .../components/screens/RouteStepsScreen.tsx | 245 ++++++++++++++---- .../components/screens/RoutesScreen.tsx | 34 ++- .../webparts/approvalAdmin/routing.test.ts | 13 + .../src/webparts/approvalAdmin/routing.ts | 14 +- frontend/src/webparts/approvalAdmin/texts.ts | 13 +- .../approvalAdmin/utils/employee.test.ts | 14 +- .../webparts/approvalAdmin/utils/employee.ts | 12 +- .../approvalAdmin/utils/stepExecutor.test.ts | 13 +- .../approvalAdmin/utils/stepExecutor.ts | 7 +- 11 files changed, 361 insertions(+), 73 deletions(-) create mode 100644 frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.module.scss diff --git a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx index 691b78b..9537c3a 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx +++ b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx @@ -37,7 +37,9 @@ export const AdminApp: React.FC = ({ api, dictionary, sharePoint api.requestTypes.get(r.requestTypeId) .then(setSelectedRequestType) .catch(() => setSelectedRequestType(undefined)); - navigate({ tab: 'routes', routeId: r.id }); + // routesFilter несём с собой: возврат «к маршрутам» должен показать тот же + // отфильтрованный список, из которого ушли (см. onBack ниже). + navigate({ tab: 'routes', routeId: r.id, routesFilter: route.routesFilter }); }; // Открытие по прямой ссылке / обновление страницы / кнопка «назад»: в URL @@ -79,7 +81,12 @@ export const AdminApp: React.FC = ({ api, dictionary, sharePoint {route.tab === 'dashboard' && } {route.tab === 'requestTypes' && } {route.tab === 'routes' && !route.routeId && ( - + )} {route.tab === 'routes' && route.routeId && selectedRoute && ( = ({ api, dictionary, sharePoint requestType={selectedRequestType} dictionary={dictionary} sharePoint={sharePoint} - onBack={() => navigate({ tab: 'routes' })} + onBack={() => navigate({ tab: 'routes', routesFilter: route.routesFilter })} /> )} {route.tab === 'resolve' && ( diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.module.scss b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.module.scss new file mode 100644 index 0000000..ec9d4ad --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.module.scss @@ -0,0 +1,56 @@ +@import '../../../../styles/design-tokens'; + +// Таблица шагов маршрута собрана вручную (а не через DataTable) из-за rowSpan: +// колонка «№» и стрелки перестановки объединяются на весь блок параллельных +// шагов. Стили повторяют ManageRequestsScreen.module.scss — там та же механика +// для строк заявки (см. ManageRequestsPhaseTable). + +.stepCol { + width: 56px; + white-space: nowrap; + text-align: center !important; +} + +// Шаг, идущий одновременно с соседним (общий orderNo) — помечаем знаком ∥, +// иначе два одинаковых номера подряд выглядят как ошибка данных. +.parallelMark { + color: $gold-600; + font-weight: 600; +} + +.flipY { + svg { transform: scaleY(-1); } +} + +// Штатную подсветку строки отключаем: ячейки с rowSpan принадлежат только +// первой строки блока, поэтому CSS :hover оставлял «вырез» в подсветке на +// границе объединённой ячейки. Подсвечиваем блок целиком через React-состояние. +.blockAwareWrap :global(tbody tr:hover) { + background: transparent; +} + +.rowHover td { + background: $gold-50; +} + +// IconButton по умолчанию прозрачная (рамка только по hover). Здесь кнопок +// много и часть неочевидна по смыслу (параллельность, перестановка) — рамка +// нужна всегда, иначе выглядит как текст. !important: это разные CSS-модули, +// порядок их правил в бандле не гарантирован. +.iconBtnVisible { + border-color: $gray-300 !important; + background: #fff !important; +} + +// «Объединить с предыдущим» — переключатель состояния, а не разовое действие, +// поэтому включённое состояние залито. +.iconBtnActive { + border-color: $gold-500 !important; + background: $gold-500 !important; + color: $navy-950 !important; + + &:hover:not(:disabled) { + border-color: $gold-600 !important; + background: $gold-600 !important; + } +} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.tsx index 3ba58d8..f9b6197 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.tsx @@ -1,14 +1,20 @@ import * as React from 'react'; +import tableStyles from '../ui/DataTable.module.scss'; +import styles from './RouteStepsScreen.module.scss'; import { RouteStepFormModal } from './RouteStepFormModal'; import { ApprovalRouteApi } from '../../services/api'; import { PortalDictionaryClient } from '../../services/portalDictionary'; import { SharePointListsClient } from '../../services/sharePointLists'; import { RequestType, RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models'; import { routeStepStatusLabels, texts } from '../../texts'; +import { shortGuid } from '../../utils/guid'; import { describeStepCondition, describeStepExecutor } from '../../utils/stepExecutor'; +import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../utils/orderBlocks'; +import { reportError } from '../../utils/errors'; import { useRouteSteps } from '../../hooks/useRouteSteps'; import { useDeleteTarget } from '../../hooks/useDeleteTarget'; import { usePortalEmployees } from '../../hooks/usePortalEmployees'; +import { usePortalDictionaries } from '../../hooks/usePortalDictionaries'; import { ActionsCell, Alert, @@ -17,7 +23,6 @@ import { Button, CellNote, ConfirmDialog, - DataTable, IconButton, PageHeader, PrimaryCell @@ -34,10 +39,17 @@ export interface IRouteStepsScreenProps { onBack: () => void; } +/** Шаг + признак «идёт одновременно с предыдущим» (выводится из равного orderNo). */ +type Row = TemplateRouteStep & { order: number; parallelWithPrev?: boolean }; + export const RouteStepsScreen: React.FC = ({ api, route, requestType, dictionary, sharePoint, onBack }) => { const steps = useRouteSteps(api, route.id); const employees = usePortalEmployees(dictionary); + const portalDictionaries = usePortalDictionaries(dictionary); const [savedWarning, setSavedWarning] = React.useState(); + const [reorderError, setReorderError] = React.useState(); + const [reordering, setReordering] = React.useState(false); + const [hoveredBlock, setHoveredBlock] = React.useState(undefined); const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`; const [editing, setEditing] = React.useState(); @@ -48,6 +60,15 @@ export const RouteStepsScreen: React.FC = ({ api, route, steps.reload ); + // Пока справочник не загрузился (или маршрут ссылается на удалённую запись), + // показываем усечённый Guid — как в RoutesScreen. + const companyName = route.companyId + ? portalDictionaries.companies.find(c => c.id === route.companyId)?.name ?? shortGuid(route.companyId) + : texts.routeSteps.anyCompany; + const departmentName = route.departmentId + ? portalDictionaries.departments.find(d => d.id === route.departmentId)?.name ?? shortGuid(route.departmentId) + : texts.routeSteps.anyDepartment; + const openCreate = (): void => { setEditing(undefined); setShowForm(true); @@ -63,6 +84,56 @@ export const RouteStepsScreen: React.FC = ({ api, route, steps.reload(); }; + // Параллельность у шагов шаблона кодируется тем же способом, что и у строк + // заявки: равный порядковый номер у соседей (отдельного флага в БД нет — + // см. utils/orderBlocks.ts и TemplateRouteStep.orderNo). markParallel/blockRanges + // работают по полю order, поэтому orderNo продублирован в него. + const withParallel = React.useMemo( + () => markParallel(steps.items.map(s => ({ ...s, order: s.orderNo }))) as Row[], + [steps.items] + ); + const blocks = React.useMemo(() => blockRanges(withParallel), [withParallel]); + + // Перестановка = пересчёт orderNo у затронутых шагов. Патчим только те, у кого + // номер реально изменился: PATCH с одним orderNo сохраняет остальные поля + // (см. PatchAsync на бэкенде — там `r.X ?? entity.X`). + const applyReorder = (mutate: (rows: Row[]) => Row[]): void => { + const renumbered = withOrdersFrom(mutate(withParallel), 0) as Row[]; + const updates = renumbered.filter(r => r.orderNo !== r.order).map(r => ({ id: r.id, orderNo: r.order })); + if (updates.length === 0) return; + + setReorderError(undefined); + setReordering(true); + Promise.all(updates.map(u => api.templateRouteSteps.patch(route.id, u.id, { orderNo: u.orderNo }))) + .then(() => steps.reload()) + .catch((e: Error) => setReorderError(texts.routeSteps.reorderFailed(reportError(e, 'RouteStepsScreen: reorder')))) + .then(() => setReordering(false), () => setReordering(false)); + }; + + const moveBlockUp = (blockIndex: number): void => { + if (blockIndex <= 0) return; + applyReorder(current => { + const a = blocks[blockIndex - 1]; + const b = blocks[blockIndex]; + return [...current.slice(0, a.start), ...current.slice(b.start, b.end + 1), ...current.slice(a.start, a.end + 1), ...current.slice(b.end + 1)]; + }); + }; + + const moveBlockDown = (blockIndex: number): void => { + if (blockIndex >= blocks.length - 1) return; + applyReorder(current => { + const a = blocks[blockIndex]; + const b = blocks[blockIndex + 1]; + return [...current.slice(0, a.start), ...current.slice(b.start, b.end + 1), ...current.slice(a.start, a.end + 1), ...current.slice(b.end + 1)]; + }); + }; + + // Первая строка списка объединяться не с чем — у неё переключатель недоступен. + const toggleParallel = (rowIndex: number): void => { + if (rowIndex <= 0) return; + applyReorder(current => current.map((r, i) => (i === rowIndex ? { ...r, parallelWithPrev: !r.parallelWithPrev } : r))); + }; + const nextOrderNo = steps.items.length > 0 ? Math.max(...steps.items.map(s => s.orderNo)) + 1 : 1; return ( @@ -76,7 +147,7 @@ export const RouteStepsScreen: React.FC = ({ api, route, /> @@ -88,62 +159,124 @@ export const RouteStepsScreen: React.FC = ({ api, route, {savedWarning && {savedWarning}} {steps.error && {steps.error}} + {reorderError && {reorderError}} {deleteTarget.error && {deleteTarget.error}} - r.orderNo }, - { - key: 'executor', - header: texts.routeSteps.columnExecutor, - render: r => { - const executor = describeStepExecutor(r, employees.items); - return ( -
- {executor.primary} - {executor.secondary && {executor.secondary}} -
- ); - } - }, - { - key: 'status', - header: texts.routeSteps.columnStatus, - render: r => ( - - {routeStepStatusLabels[r.status]} - - ) - }, - { - key: 'reassign', - header: texts.routeSteps.columnReassign, - align: 'center', - render: r => (r.allowReassignToOtherApprovers ? {texts.routeSteps.reassignAllowed} : {texts.common.no}) - }, - { - key: 'condition', - header: texts.routeSteps.columnCondition, - render: r => describeStepCondition(r) ?? texts.common.empty - }, - { - key: 'actions', - header: '', - width: '100px', - align: 'right', - sticky: true, - render: r => ( - - openEdit(r)} /> - deleteTarget.ask(r)} /> - - ) - } - ]} - rows={steps.items} - keyOf={r => r.id} - emptyMessage={steps.isLoading ? texts.common.loading : texts.routeSteps.emptyList} - /> + {steps.items.length === 0 && ( +
+
{steps.isLoading ? texts.common.loading : texts.routeSteps.emptyList}
+
+ )} + + {steps.items.length > 0 && ( +
+
+ + + + + + + + + + + + {withParallel.map((r, i) => { + const blockIndex = blockIndexOf(blocks, i); + const block = blocks[blockIndex]; + const isBlockStart = block.start === i; + const executor = describeStepExecutor(r, employees.items); + + return ( + setHoveredBlock(blockIndex)} + onMouseLeave={() => setHoveredBlock(undefined)} + > + {isBlockStart && ( + + )} + + + + + {/* Стрелки — одна пара на весь блок (rowSpan): двигают шаг + целиком, а не отдельного участника параллельной группы. */} + {isBlockStart && ( + + )} + + + ); + })} + +
{texts.routeSteps.columnOrderNo}{texts.routeSteps.columnExecutor}{texts.routeSteps.columnStatus}{texts.routeSteps.columnReassign}{texts.routeSteps.columnCondition} + +
+ {block.end > block.start ? ( + + {r.order} ∥ + + ) : ( + r.order + )} + + {executor.primary} + {executor.secondary && {executor.secondary}} + + + {routeStepStatusLabels[r.status]} + + + {r.allowReassignToOtherApprovers + ? {texts.routeSteps.reassignAllowed} + : {texts.common.no}} + {describeStepCondition(r) ?? texts.common.empty} + + moveBlockUp(blockIndex)} + /> + = blocks.length - 1} + onClick={() => moveBlockDown(blockIndex)} + /> + + + + toggleParallel(i)} + /> + openEdit(r)} + /> + deleteTarget.ask(r)} + /> + +
+
+
+ )} {showForm && ( void; } -export const RoutesScreen: React.FC = ({ api, dictionary, onOpenSteps }) => { +// Фильтр по типу заявки переживает перезагрузку: администратор обычно работает +// с одним типом заявок подряд, и сбрасывать выбор на каждый заход неудобно. +const ROUTES_FILTER_STORAGE_KEY = 'approvalAdmin.routesFilter'; + +export const RoutesScreen: React.FC = ({ + api, + dictionary, + initialFilterRequestTypeId, + onOpenSteps +}) => { const requestTypes = useRequestTypes(api); const portalDictionaries = usePortalDictionaries(dictionary); - const [filterRequestTypeId, setFilterRequestTypeId] = React.useState(''); + const [filterRequestTypeId, setFilterRequestTypeId] = useLocalStorageState(ROUTES_FILTER_STORAGE_KEY, ''); + + // Открыли по ссылке с фильтром — она сильнее сохранённого значения. Только на + // монтировании: дальше источник истины — сам экран (он же пишет фильтр в URL + // ниже), иначе собственный replaceRoute тут же вернулся бы сюда как «внешний». + React.useEffect(() => { + if (initialFilterRequestTypeId) setFilterRequestTypeId(initialFilterRequestTypeId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Держим URL синхронным с фильтром — ссылку на отфильтрованный список можно + // скопировать. replaceRoute, а не navigate: смена фильтра не должна плодить + // записи в истории браузера. + React.useEffect(() => { + replaceRoute({ tab: 'routes', routesFilter: filterRequestTypeId }); + }, [filterRequestTypeId]); + const routes = useTemplateRoutes(api, filterRequestTypeId ? Number(filterRequestTypeId) : undefined); const [editing, setEditing] = React.useState(); @@ -87,6 +116,7 @@ export const RoutesScreen: React.FC = ({ api, dictionary, on `#${r.id}` }, { key: 'requestType', header: texts.routes.columnRequestType, diff --git a/frontend/src/webparts/approvalAdmin/routing.test.ts b/frontend/src/webparts/approvalAdmin/routing.test.ts index 08baad5..5246dae 100644 --- a/frontend/src/webparts/approvalAdmin/routing.test.ts +++ b/frontend/src/webparts/approvalAdmin/routing.test.ts @@ -37,6 +37,19 @@ describe('parseRoute', () => { expect(parseRoute('?aatab=routes').routeId).toBeUndefined(); }); + it('aarfilter учитывается только при tab=routes', () => { + expect(parseRoute('?aatab=routes&aarfilter=11').routesFilter).toBe('11'); + expect(parseRoute('?aatab=manageRequests&aarfilter=11').routesFilter).toBeUndefined(); + }); + + // Фильтр живёт и при открытых шагах: возврат «к маршрутам» должен вернуть + // к тому же отфильтрованному списку (см. AdminApp.onBack). + it('aarfilter читается вместе с aaroute', () => { + const route = parseRoute('?aatab=routes&aaroute=7&aarfilter=11'); + expect(route.routeId).toBe(7); + expect(route.routesFilter).toBe('11'); + }); + it('resolve.fieldValues парсит валидный JSON-массив', () => { const fields = encodeURIComponent(JSON.stringify([{ fieldName: 'Amount', value: '100' }])); const route = parseRoute(`?aatab=resolve&aartype=1&aafields=${fields}`); diff --git a/frontend/src/webparts/approvalAdmin/routing.ts b/frontend/src/webparts/approvalAdmin/routing.ts index b2736c8..3e99d88 100644 --- a/frontend/src/webparts/approvalAdmin/routing.ts +++ b/frontend/src/webparts/approvalAdmin/routing.ts @@ -43,6 +43,12 @@ export interface IAdminRoute { /** Id маршрута — только при tab='routes': сразу открывает шаги ЭТОГО маршрута, * минуя список (см. AdminApp — по нему домаршрут дочитывается через api.templateRoutes.get). */ routeId?: number; + /** + * Фильтр списка маршрутов по типу заявки (id как строка, '' — все типы). + * Живёт и при открытых шагах (routeId): возврат «к маршрутам» должен вернуть + * к тому же отфильтрованному списку, а не сбросить фильтр. + */ + routesFilter?: string; /** Задан только при tab='resolve' и непустом requestTypeId. */ resolve?: IResolveRouteParams; /** Задан только при tab='manageRequests' и непустых requestTypeId+requestId. */ @@ -56,7 +62,7 @@ const ROUTE_CHANGE_EVENT = 'aa-route-change'; const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'manageRequests']; const PARAM_KEYS = [ - 'aatab', 'aaroute', + 'aatab', 'aaroute', 'aarfilter', 'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept', 'aamtype', 'aamreq' ]; @@ -114,6 +120,7 @@ export function parseRoute(search: string = window.location.search): IAdminRoute return { tab, routeId: routeId && !isNaN(routeId) ? routeId : undefined, + routesFilter: (tab === 'routes' ? params.get('aarfilter') : null) || undefined, resolve, manage }; @@ -127,8 +134,9 @@ function buildQuery(route: IAdminRoute): string { if (route.tab !== 'dashboard') params.set('aatab', route.tab); - if (route.tab === 'routes' && route.routeId) { - params.set('aaroute', String(route.routeId)); + if (route.tab === 'routes') { + if (route.routeId) params.set('aaroute', String(route.routeId)); + if (route.routesFilter) params.set('aarfilter', route.routesFilter); } if (route.tab === 'resolve' && route.resolve && route.resolve.requestTypeId) { diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts index 0ff23df..c60d1bd 100644 --- a/frontend/src/webparts/approvalAdmin/texts.ts +++ b/frontend/src/webparts/approvalAdmin/texts.ts @@ -159,6 +159,7 @@ export const texts = { filterAll: 'Все типы заявок', dictionaryFailed: (message: string): string => `Справочник компаний и отделов недоступен: ${message}`, emptyList: 'Маршрутов пока нет.', + columnId: '№ маршрута', columnRequestType: 'Тип заявки', columnCompany: 'Компания', columnDepartment: 'Отдел', @@ -194,10 +195,20 @@ export const texts = { routeSteps: { breadcrumb: (routeId: number): string => `Шаги маршрута #${routeId}`, title: (routeId: number): string => `Маршрут #${routeId}`, - subtitle: (requestTypeName: string): string => requestTypeName, + /** Подзаголовок экрана шагов: по нему видно, ЧЕЙ это маршрут (тип заявки · компания · отдел). */ + subtitle: (requestTypeName: string, company: string, department: string): string => + `${requestTypeName} · ${company} · ${department}`, + /** Маршрут без компании/отдела — запасной, применяется когда своего маршрута нет. */ + anyCompany: 'любая компания', + anyDepartment: 'любой отдел', back: 'К маршрутам', add: 'Добавить шаг', emptyList: 'Шагов пока нет — добавьте первый.', + moveUp: 'Шаг выше', + moveDown: 'Шаг ниже', + mergeWithPrev: 'Объединить с предыдущим шагом (выполнять параллельно)', + splitFromPrev: 'Отделить от предыдущего шага (выполнять после него)', + reorderFailed: (message: string): string => `Не удалось изменить порядок шагов: ${message}`, columnOrderNo: '№', columnExecutor: 'Исполнитель', columnStatus: 'Статус', diff --git a/frontend/src/webparts/approvalAdmin/utils/employee.test.ts b/frontend/src/webparts/approvalAdmin/utils/employee.test.ts index ccf6096..484d276 100644 --- a/frontend/src/webparts/approvalAdmin/utils/employee.test.ts +++ b/frontend/src/webparts/approvalAdmin/utils/employee.test.ts @@ -26,11 +26,21 @@ describe('findEmployeeByEmail', () => { }); describe('toEmployeeOption', () => { - it('строит опцию для SearchableSelect из сотрудника', () => { + it('строит опцию для SearchableSelect из сотрудника: должность и email в подписи', () => { expect(toEmployeeOption(employees[0])).toEqual({ value: 'gorbenko.pavel@tnsu.kz', label: 'Горбенко Павел', - sublabel: 'gorbenko.pavel@tnsu.kz' + sublabel: 'Разработчик middle · gorbenko.pavel@tnsu.kz' + }); + }); + + // Должность в справочнике заполнена не у всех — тогда подпись не должна + // начинаться с висящего разделителя. + it('без должности в подписи остаётся только email', () => { + expect(toEmployeeOption({ fio: 'Без Должности', email: 'nobody@tnsu.kz', position: '' })).toEqual({ + value: 'nobody@tnsu.kz', + label: 'Без Должности', + sublabel: 'nobody@tnsu.kz' }); }); }); diff --git a/frontend/src/webparts/approvalAdmin/utils/employee.ts b/frontend/src/webparts/approvalAdmin/utils/employee.ts index 07fd96e..ee2fead 100644 --- a/frontend/src/webparts/approvalAdmin/utils/employee.ts +++ b/frontend/src/webparts/approvalAdmin/utils/employee.ts @@ -1,9 +1,17 @@ import { Employee } from '../models'; import { ISearchableOption } from '../components/ui'; -/** Опция для SearchableSelect: ФИО — основная строка, email — маленькая подпись под ней. */ +/** + * Опция для SearchableSelect: ФИО — основная строка, должность и email — подпись под ней. + * + * Должность здесь не только для узнавания однофамильцев: SearchableSelect ищет и по + * sublabel (см. filtered там), поэтому согласующего можно найти, набрав «начальник омтс», + * не помня фамилии. Должность может быть пустой (в справочнике не у всех заполнена) — + * тогда в подписи остаётся только email. + */ export function toEmployeeOption(employee: Employee): ISearchableOption { - return { value: employee.email, label: employee.fio, sublabel: employee.email }; + const sublabel = [employee.position, employee.email].filter(Boolean).join(' · '); + return { value: employee.email, label: employee.fio, sublabel }; } export function findEmployeeByEmail(employees: Employee[], email: string | undefined): Employee | undefined { diff --git a/frontend/src/webparts/approvalAdmin/utils/stepExecutor.test.ts b/frontend/src/webparts/approvalAdmin/utils/stepExecutor.test.ts index 4816b7d..1593331 100644 --- a/frontend/src/webparts/approvalAdmin/utils/stepExecutor.test.ts +++ b/frontend/src/webparts/approvalAdmin/utils/stepExecutor.test.ts @@ -46,9 +46,18 @@ describe('detectExecutorKind', () => { describe('describeStepExecutor', () => { const employees: Employee[] = [{ fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик' }]; - it('для email найденного сотрудника показывает ФИО и email отдельной строкой', () => { + it('для email найденного сотрудника показывает ФИО, а должность и email — отдельной строкой', () => { const result = describeStepExecutor(step({ executorEmail: 'gorbenko.pavel@tnsu.kz' }), employees); - expect(result).toEqual({ primary: 'Горбенко Павел', secondary: 'gorbenko.pavel@tnsu.kz' }); + expect(result).toEqual({ primary: 'Горбенко Павел', secondary: 'Разработчик · gorbenko.pavel@tnsu.kz' }); + }); + + // Должность в справочнике заполнена не у всех — тогда подпись не должна + // начинаться с висящего разделителя. + it('для сотрудника без должности в подписи остаётся только email', () => { + const result = describeStepExecutor(step({ executorEmail: 'nopos@tnsu.kz' }), [ + { fio: 'Без Должности', email: 'nopos@tnsu.kz', position: '' } + ]); + expect(result).toEqual({ primary: 'Без Должности', secondary: 'nopos@tnsu.kz' }); }); it('для email, которого нет в справочнике, показывает сам email без secondary', () => { diff --git a/frontend/src/webparts/approvalAdmin/utils/stepExecutor.ts b/frontend/src/webparts/approvalAdmin/utils/stepExecutor.ts index d2e3d0f..c396685 100644 --- a/frontend/src/webparts/approvalAdmin/utils/stepExecutor.ts +++ b/frontend/src/webparts/approvalAdmin/utils/stepExecutor.ts @@ -19,7 +19,7 @@ export function detectExecutorKind(step: { export interface StepExecutorDisplay { primary: string; - /** Email под ФИО — только когда исполнитель задан конкретным сотрудником и тот найден в справочнике. */ + /** Должность и email под ФИО — когда исполнитель задан конкретным сотрудником и тот найден в справочнике. */ secondary?: string; } @@ -27,7 +27,10 @@ export interface StepExecutorDisplay { export function describeStepExecutor(step: TemplateRouteStep, employees: Employee[]): StepExecutorDisplay { if (step.executorEmail) { const employee = findEmployeeByEmail(employees, step.executorEmail ?? undefined); - return employee ? { primary: employee.fio, secondary: employee.email } : { primary: step.executorEmail }; + if (!employee) return { primary: step.executorEmail }; + // Должность рядом с email: по одному ФИО не видно, тот ли это человек, что + // задумывался при настройке маршрута (в справочнике есть однофамильцы). + return { primary: employee.fio, secondary: [employee.position, employee.email].filter(Boolean).join(' · ') }; } if (step.position) return { primary: step.position }; if (step.projectRole && step.projectRole !== ProjectRole.None) {