Обходной лист #12

Merged
gorbenko.pavel merged 11 commits from clearance-form into main 2026-08-17 05:43:52 +00:00
6 changed files with 149 additions and 10 deletions
Showing only changes of commit f2c36506ed - Show all commits
@@ -32,6 +32,15 @@ import {
export interface IRouteStepFormModalProps { export interface IRouteStepFormModalProps {
api: ApprovalRouteApi; api: ApprovalRouteApi;
route: TemplateRoute; route: TemplateRoute;
/**
* Название компании маршрута, если она задана (route.companyId) — undefined
* для запасного маршрута («любая компания»). Резолв шага «по должности»
* ищет исполнителя ИМЕННО в этой компании (initiatorCompany = компания
* инициатора, а маршрут с заданной компанией применяется только к таким
* инициаторам), поэтому подсказка должности должна сужаться под неё же —
* иначе показала бы человека, которого этот маршрут никогда не найдёт.
*/
routeCompanyName?: string;
/** Undefined, пока грузится тип заявки маршрута. */ /** Undefined, пока грузится тип заявки маршрута. */
requestType?: RequestType; requestType?: RequestType;
sharePoint: SharePointListsClient; sharePoint: SharePointListsClient;
@@ -132,6 +141,7 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
api, api,
route, route,
routeCompanyName,
requestType, requestType,
sharePoint, sharePoint,
editing, editing,
@@ -208,11 +218,42 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
} }
if (positionMatches.exact.length > 0) { if (positionMatches.exact.length > 0) {
// У маршрута задана конкретная компания — резолв ищет исполнителя ИМЕННО
// в ней (route.companyId сужает круг инициаторов до этой компании, а
// бэкенд ищет по компании инициатора). Найденные в других компаниях
// этому маршруту не подойдут — их надо не прятать, а объяснить, что они не в счёт.
const inRouteCompany = routeCompanyName
? positionMatches.exact.filter(e => e.company === routeCompanyName)
: positionMatches.exact;
if (inRouteCompany.length > 0) {
return (
<div className={`${styles.positionPreview} ${styles.previewOk}`}>
<span className={styles.previewTitle}>
{routeCompanyName
? texts.routeSteps.positionPreviewFoundInCompany(routeCompanyName, inRouteCompany.length)
: texts.routeSteps.positionPreviewFound(inRouteCompany.length)}
</span>
<ul className={styles.previewList}>
{inRouteCompany.map(e => (
<li key={e.email}>
<span className={styles.previewPerson}>{e.fio}</span>
{e.company && <span className={styles.previewCompany}> · {e.company}</span>}
</li>
))}
</ul>
</div>
);
}
// routeCompanyName задан, но среди найденных нет никого из этой компании —
// должность существует, но не там, где её будет искать этот маршрут.
return ( return (
<div className={`${styles.positionPreview} ${styles.previewOk}`}> <div className={`${styles.positionPreview} ${styles.previewMiss}`}>
<span className={styles.previewTitle}> <span className={styles.previewTitle}>
{texts.routeSteps.positionPreviewFound(positionMatches.exact.length)} {texts.routeSteps.positionPreviewWrongCompany(routeCompanyName as string)}
</span> </span>
<div className={styles.previewTitle}>{texts.routeSteps.positionPreviewOtherCompanies}</div>
<ul className={styles.previewList}> <ul className={styles.previewList}>
{positionMatches.exact.map(e => ( {positionMatches.exact.map(e => (
<li key={e.email}> <li key={e.email}>
@@ -11,6 +11,33 @@
text-align: center !important; 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) — помечаем знаком ∥, // Шаг, идущий одновременно с соседним (общий orderNo) — помечаем знаком ∥,
// иначе два одинаковых номера подряд выглядят как ошибка данных. // иначе два одинаковых номера подряд выглядят как ошибка данных.
.parallelMark { .parallelMark {
@@ -174,7 +174,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
<table className={tableStyles.table}> <table className={tableStyles.table}>
<thead> <thead>
<tr> <tr>
<th className={styles.stepCol}>{texts.routeSteps.columnOrderNo}</th> <th className={`${styles.stepCol} ${styles.stepColSticky}`}>{texts.routeSteps.columnOrderNo}</th>
<th>{texts.routeSteps.columnExecutor}</th> <th>{texts.routeSteps.columnExecutor}</th>
<th>{texts.routeSteps.columnStatus}</th> <th>{texts.routeSteps.columnStatus}</th>
<th style={{ textAlign: 'center' }}>{texts.routeSteps.columnReassign}</th> <th style={{ textAlign: 'center' }}>{texts.routeSteps.columnReassign}</th>
@@ -198,7 +198,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
onMouseLeave={() => setHoveredBlock(undefined)} onMouseLeave={() => setHoveredBlock(undefined)}
> >
{isBlockStart && ( {isBlockStart && (
<td className={styles.stepCol} rowSpan={block.end - block.start + 1}> <td className={`${styles.stepCol} ${styles.stepColSticky}`} rowSpan={block.end - block.start + 1}>
{block.end > block.start ? ( {block.end > block.start ? (
<span className={styles.parallelMark} title={texts.routeSteps.mergeWithPrev}> <span className={styles.parallelMark} title={texts.routeSteps.mergeWithPrev}>
{r.order} ∥ {r.order} ∥
@@ -280,6 +280,10 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
{showForm && ( {showForm && (
<RouteStepFormModal <RouteStepFormModal
// Undefined, если у маршрута компания не задана (запасной маршрут,
// применяется к любой компании инициатора) — тогда подсказка
// должности показывает всех, как раньше, а не сужает по компании.
routeCompanyName={route.companyId ? companyName : undefined}
api={api} api={api}
route={route} route={route}
requestType={requestType} requestType={requestType}
@@ -271,6 +271,18 @@ export const texts = {
? 'Сейчас эту должность занимает:' ? 'Сейчас эту должность занимает:'
: `Эту должность занимают ${count} ${pluralRu(count, ['сотрудник', 'сотрудника', 'сотрудников'])} — ` + : `Эту должность занимают ${count} ${pluralRu(count, ['сотрудник', 'сотрудника', 'сотрудников'])} — ` +
'на согласование пойдёт тот, чья компания совпадёт с компанией инициатора:', 'на согласование пойдёт тот, чья компания совпадёт с компанией инициатора:',
// У маршрута задана конкретная компания (не запасной «любая компания») —
// резолвиться будет только сотрудник этой компании, остальные найденные
// (в других компаниях) этому маршруту не подойдут ни при каких условиях.
positionPreviewFoundInCompany: (company: string, count: number): string =>
count === 1
? `Эту должность в компании «${company}» (компания маршрута) занимает:`
: `Эту должность в компании «${company}» (компания маршрута) занимают ${count} ` +
`${pluralRu(count, ['сотрудник', 'сотрудника', 'сотрудников'])}:`,
positionPreviewWrongCompany: (company: string): string =>
`Такая должность есть в справочнике, но не в компании «${company}» — а маршрут применяется именно к её ` +
'инициаторам. При резолве шаг останется без исполнителя.',
positionPreviewOtherCompanies: 'Есть в других компаниях:',
positionPreviewNone: positionPreviewNone:
'Точно такой должности в справочнике нет — на резолве шаг останется без исполнителя. Проверьте написание.', 'Точно такой должности в справочнике нет — на резолве шаг останется без исполнителя. Проверьте написание.',
positionPreviewSimilar: 'Похожие должности в справочнике (нажмите, чтобы подставить точное написание):', positionPreviewSimilar: 'Похожие должности в справочнике (нажмите, чтобы подставить точное написание):',
@@ -1,9 +1,9 @@
import { Employee } from '../models'; import { Employee } from '../models';
import { findEmployeeByEmail, toEmployeeOption } from './employee'; import { findEmployeeByEmail, matchEmployeesByPosition, toEmployeeOption } from './employee';
const employees: Employee[] = [ const employees: Employee[] = [
{ fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик middle' }, { fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик middle', company: 'ТОО TANSU Construction' },
{ fio: 'Соколов Давид', email: 'sokolov.david@tnsu.kz', position: 'Руководитель отдела' } { fio: 'Соколов Давид', email: 'sokolov.david@tnsu.kz', position: 'Руководитель отдела', company: 'ТОО TANSU Construction' }
]; ];
describe('findEmployeeByEmail', () => { describe('findEmployeeByEmail', () => {
@@ -37,10 +37,63 @@ describe('toEmployeeOption', () => {
// Должность в справочнике заполнена не у всех — тогда подпись не должна // Должность в справочнике заполнена не у всех — тогда подпись не должна
// начинаться с висящего разделителя. // начинаться с висящего разделителя.
it('без должности в подписи остаётся только email', () => { 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', value: 'nobody@tnsu.kz',
label: 'Без Должности', label: 'Без Должности',
sublabel: 'nobody@tnsu.kz' 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);
});
});
@@ -44,7 +44,9 @@ describe('detectExecutorKind', () => {
}); });
describe('describeStepExecutor', () => { 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 — отдельной строкой', () => { it('для email найденного сотрудника показывает ФИО, а должность и email — отдельной строкой', () => {
const result = describeStepExecutor(step({ executorEmail: 'gorbenko.pavel@tnsu.kz' }), employees); const result = describeStepExecutor(step({ executorEmail: 'gorbenko.pavel@tnsu.kz' }), employees);
@@ -55,7 +57,7 @@ describe('describeStepExecutor', () => {
// начинаться с висящего разделителя. // начинаться с висящего разделителя.
it('для сотрудника без должности в подписи остаётся только email', () => { it('для сотрудника без должности в подписи остаётся только email', () => {
const result = describeStepExecutor(step({ executorEmail: 'nopos@tnsu.kz' }), [ 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' }); expect(result).toEqual({ primary: 'Без Должности', secondary: 'nopos@tnsu.kz' });
}); });