From f2c36506ed747f98c45a85536b64b00f1d6f2a47 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 14 Aug 2026 14:25:15 +0500 Subject: [PATCH] =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=BD=D1=8B=D0=B5=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/screens/RouteStepFormModal.tsx | 45 +++++++++++++- .../screens/RouteStepsScreen.module.scss | 27 ++++++++ .../components/screens/RouteStepsScreen.tsx | 8 ++- frontend/src/webparts/approvalAdmin/texts.ts | 12 ++++ .../approvalAdmin/utils/employee.test.ts | 61 +++++++++++++++++-- .../approvalAdmin/utils/stepExecutor.test.ts | 6 +- 6 files changed, 149 insertions(+), 10 deletions(-) diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx index 9dc5c5c..046ce26 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx @@ -32,6 +32,15 @@ import { export interface IRouteStepFormModalProps { api: ApprovalRouteApi; route: TemplateRoute; + /** + * Название компании маршрута, если она задана (route.companyId) — undefined + * для запасного маршрута («любая компания»). Резолв шага «по должности» + * ищет исполнителя ИМЕННО в этой компании (initiatorCompany = компания + * инициатора, а маршрут с заданной компанией применяется только к таким + * инициаторам), поэтому подсказка должности должна сужаться под неё же — + * иначе показала бы человека, которого этот маршрут никогда не найдёт. + */ + routeCompanyName?: string; /** Undefined, пока грузится тип заявки маршрута. */ requestType?: RequestType; sharePoint: SharePointListsClient; @@ -132,6 +141,7 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number export const RouteStepFormModal: React.FC = ({ api, route, + routeCompanyName, requestType, sharePoint, editing, @@ -208,11 +218,42 @@ export const RouteStepFormModal: React.FC = ({ } if (positionMatches.exact.length > 0) { + // У маршрута задана конкретная компания — резолв ищет исполнителя ИМЕННО + // в ней (route.companyId сужает круг инициаторов до этой компании, а + // бэкенд ищет по компании инициатора). Найденные в других компаниях + // этому маршруту не подойдут — их надо не прятать, а объяснить, что они не в счёт. + const inRouteCompany = routeCompanyName + ? positionMatches.exact.filter(e => e.company === routeCompanyName) + : positionMatches.exact; + + if (inRouteCompany.length > 0) { + return ( +
+ + {routeCompanyName + ? texts.routeSteps.positionPreviewFoundInCompany(routeCompanyName, inRouteCompany.length) + : texts.routeSteps.positionPreviewFound(inRouteCompany.length)} + +
    + {inRouteCompany.map(e => ( +
  • + {e.fio} + {e.company && · {e.company}} +
  • + ))} +
+
+ ); + } + + // routeCompanyName задан, но среди найденных нет никого из этой компании — + // должность существует, но не там, где её будет искать этот маршрут. return ( -
+
- {texts.routeSteps.positionPreviewFound(positionMatches.exact.length)} + {texts.routeSteps.positionPreviewWrongCompany(routeCompanyName as string)} +
{texts.routeSteps.positionPreviewOtherCompanies}
    {positionMatches.exact.map(e => (
  • diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.module.scss b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.module.scss index ec9d4ad..651ee8c 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.module.scss @@ -11,6 +11,33 @@ text-align: center !important; } +// Колонка «№» закреплена слева при горизонтальном скролле — на узких экранах/ +// в редакторе SharePoint таблица шире контейнера, и без этого номер шага +// (единственный ориентир при длинном горизонтальном скролле) уезжал за край +// вместе с остальными колонками. Зеркало правого .stickyCell в +// DataTable.module.scss (там — action-колонка у правого края). +.stepColSticky { + position: sticky; + left: 0; + background: #fff; + box-shadow: 6px 0 6px -6px rgba(15, 36, 64, .12); +} + +.table thead th.stepColSticky { + background: $gray-50; + z-index: 2; +} + +.table tbody td.stepColSticky { + z-index: 1; +} + +// Явно, а не полагаясь на специфичность: .rowHover td и .stepColSticky иначе +// шли бы вровень, и было бы неочевидно, какой фон победит при следующей правке. +.rowHover td.stepColSticky { + background: $gold-50; +} + // Шаг, идущий одновременно с соседним (общий orderNo) — помечаем знаком ∥, // иначе два одинаковых номера подряд выглядят как ошибка данных. .parallelMark { diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.tsx index f9b6197..7846f19 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.tsx @@ -174,7 +174,7 @@ export const RouteStepsScreen: React.FC = ({ api, route, - + @@ -198,7 +198,7 @@ export const RouteStepsScreen: React.FC = ({ api, route, onMouseLeave={() => setHoveredBlock(undefined)} > {isBlockStart && ( -
    {texts.routeSteps.columnOrderNo}{texts.routeSteps.columnOrderNo} {texts.routeSteps.columnExecutor} {texts.routeSteps.columnStatus} {texts.routeSteps.columnReassign} + {block.end > block.start ? ( {r.order} ∥ @@ -280,6 +280,10 @@ export const RouteStepsScreen: React.FC = ({ api, route, {showForm && ( + count === 1 + ? `Эту должность в компании «${company}» (компания маршрута) занимает:` + : `Эту должность в компании «${company}» (компания маршрута) занимают ${count} ` + + `${pluralRu(count, ['сотрудник', 'сотрудника', 'сотрудников'])}:`, + positionPreviewWrongCompany: (company: string): string => + `Такая должность есть в справочнике, но не в компании «${company}» — а маршрут применяется именно к её ` + + 'инициаторам. При резолве шаг останется без исполнителя.', + positionPreviewOtherCompanies: 'Есть в других компаниях:', positionPreviewNone: 'Точно такой должности в справочнике нет — на резолве шаг останется без исполнителя. Проверьте написание.', positionPreviewSimilar: 'Похожие должности в справочнике (нажмите, чтобы подставить точное написание):', diff --git a/frontend/src/webparts/approvalAdmin/utils/employee.test.ts b/frontend/src/webparts/approvalAdmin/utils/employee.test.ts index 484d276..7005da0 100644 --- a/frontend/src/webparts/approvalAdmin/utils/employee.test.ts +++ b/frontend/src/webparts/approvalAdmin/utils/employee.test.ts @@ -1,9 +1,9 @@ import { Employee } from '../models'; -import { findEmployeeByEmail, toEmployeeOption } from './employee'; +import { findEmployeeByEmail, matchEmployeesByPosition, toEmployeeOption } from './employee'; const employees: Employee[] = [ - { fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик middle' }, - { fio: 'Соколов Давид', email: 'sokolov.david@tnsu.kz', position: 'Руководитель отдела' } + { fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик middle', company: 'ТОО TANSU Construction' }, + { fio: 'Соколов Давид', email: 'sokolov.david@tnsu.kz', position: 'Руководитель отдела', company: 'ТОО TANSU Construction' } ]; describe('findEmployeeByEmail', () => { @@ -37,10 +37,63 @@ describe('toEmployeeOption', () => { // Должность в справочнике заполнена не у всех — тогда подпись не должна // начинаться с висящего разделителя. it('без должности в подписи остаётся только email', () => { - expect(toEmployeeOption({ fio: 'Без Должности', email: 'nobody@tnsu.kz', position: '' })).toEqual({ + expect(toEmployeeOption({ fio: 'Без Должности', email: 'nobody@tnsu.kz', position: '', company: '' })).toEqual({ value: 'nobody@tnsu.kz', label: 'Без Должности', sublabel: 'nobody@tnsu.kz' }); }); }); + +describe('matchEmployeesByPosition', () => { + const staff: Employee[] = [ + { fio: 'Плотникова Лариса', email: 'p@tnsu.kz', position: 'Генеральный директор', company: 'Компания А' }, + { fio: 'Самаева Асель', email: 's@tnsu.kz', position: 'Директор', company: 'Компания А' }, + { fio: 'Ким Николай', email: 'k@tnsu.kz', position: 'Управляющий директор', company: 'Компания Б' }, + { fio: 'Иванов Иван', email: 'i1@tnsu.kz', position: 'Инженер ПТО', company: 'Компания А' }, + { fio: 'Петров Пётр', email: 'i2@tnsu.kz', position: 'Инженер ПТО', company: 'Компания Б' }, + { fio: 'Без Должности', email: 'n@tnsu.kz', position: '', company: 'Компания А' } + ]; + + it('пустой ввод — ничего не подбирает', () => { + expect(matchEmployeesByPosition(staff, ' ')).toEqual({ exact: [], similar: [] }); + }); + + // Бэкенд ищет точное совпадение должности, поэтому подсказка обязана считать + // так же — иначе обещала бы исполнителя там, где его не найдут. + it('точное совпадение не зависит от регистра и краевых пробелов', () => { + const result = matchEmployeesByPosition(staff, ' генеральный ДИРЕКТОР '); + expect(result.exact.map(e => e.fio)).toEqual(['Плотникова Лариса']); + }); + + it('одну должность в разных компаниях занимают несколько человек — возвращаются все', () => { + const result = matchEmployeesByPosition(staff, 'Инженер ПТО'); + expect(result.exact.map(e => e.company)).toEqual(['Компания А', 'Компания Б']); + }); + + // 'директор' — сам по себе точное совпадение (должность Самаевой), а + // подстрокой входит ещё в две других — те и предлагаются похожими. + it('точное совпадение и похожие должности возвращаются одновременно', () => { + const result = matchEmployeesByPosition(staff, 'директор'); + expect(result.exact.map(e => e.fio)).toEqual(['Самаева Асель']); + expect(result.similar.map(s => s.position)).toEqual(['Генеральный директор', 'Управляющий директор']); + }); + + // Список нужен, чтобы подставить точное написание — одинаковые строки в нём бесполезны. + it('похожие схлопываются по написанию и считают людей', () => { + expect(matchEmployeesByPosition(staff, 'инженер').similar).toEqual([ + { position: 'Инженер ПТО', fio: 'Иванов Иван', count: 2 } + ]); + }); + + it('точное совпадение не дублируется в похожих', () => { + const result = matchEmployeesByPosition(staff, 'Директор'); + expect(result.exact.map(e => e.fio)).toEqual(['Самаева Асель']); + expect(result.similar.some(s => s.position === 'Директор')).toBe(false); + }); + + it('сотрудники без должности не попадают в подбор', () => { + expect(matchEmployeesByPosition(staff, '').exact).toEqual([]); + expect(matchEmployeesByPosition(staff, 'директор').similar.some(s => s.position === '')).toBe(false); + }); +}); diff --git a/frontend/src/webparts/approvalAdmin/utils/stepExecutor.test.ts b/frontend/src/webparts/approvalAdmin/utils/stepExecutor.test.ts index 1593331..6cd149c 100644 --- a/frontend/src/webparts/approvalAdmin/utils/stepExecutor.test.ts +++ b/frontend/src/webparts/approvalAdmin/utils/stepExecutor.test.ts @@ -44,7 +44,9 @@ describe('detectExecutorKind', () => { }); describe('describeStepExecutor', () => { - const employees: Employee[] = [{ fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик' }]; + const employees: Employee[] = [ + { fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик', company: 'ТОО TANSU Construction' } + ]; it('для email найденного сотрудника показывает ФИО, а должность и email — отдельной строкой', () => { const result = describeStepExecutor(step({ executorEmail: 'gorbenko.pavel@tnsu.kz' }), employees); @@ -55,7 +57,7 @@ describe('describeStepExecutor', () => { // начинаться с висящего разделителя. it('для сотрудника без должности в подписи остаётся только email', () => { const result = describeStepExecutor(step({ executorEmail: 'nopos@tnsu.kz' }), [ - { fio: 'Без Должности', email: 'nopos@tnsu.kz', position: '' } + { fio: 'Без Должности', email: 'nopos@tnsu.kz', position: '', company: '' } ]); expect(result).toEqual({ primary: 'Без Должности', secondary: 'nopos@tnsu.kz' }); });