разные правки
This commit is contained in:
@@ -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<IRouteStepFormModalProps> = ({
|
||||
api,
|
||||
route,
|
||||
routeCompanyName,
|
||||
requestType,
|
||||
sharePoint,
|
||||
editing,
|
||||
@@ -208,11 +218,42 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
}
|
||||
|
||||
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}>
|
||||
{texts.routeSteps.positionPreviewFound(positionMatches.exact.length)}
|
||||
{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 (
|
||||
<div className={`${styles.positionPreview} ${styles.previewMiss}`}>
|
||||
<span className={styles.previewTitle}>
|
||||
{texts.routeSteps.positionPreviewWrongCompany(routeCompanyName as string)}
|
||||
</span>
|
||||
<div className={styles.previewTitle}>{texts.routeSteps.positionPreviewOtherCompanies}</div>
|
||||
<ul className={styles.previewList}>
|
||||
{positionMatches.exact.map(e => (
|
||||
<li key={e.email}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -174,7 +174,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
||||
<table className={tableStyles.table}>
|
||||
<thead>
|
||||
<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.columnStatus}</th>
|
||||
<th style={{ textAlign: 'center' }}>{texts.routeSteps.columnReassign}</th>
|
||||
@@ -198,7 +198,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
||||
onMouseLeave={() => setHoveredBlock(undefined)}
|
||||
>
|
||||
{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 ? (
|
||||
<span className={styles.parallelMark} title={texts.routeSteps.mergeWithPrev}>
|
||||
{r.order} ∥
|
||||
@@ -280,6 +280,10 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
||||
|
||||
{showForm && (
|
||||
<RouteStepFormModal
|
||||
// Undefined, если у маршрута компания не задана (запасной маршрут,
|
||||
// применяется к любой компании инициатора) — тогда подсказка
|
||||
// должности показывает всех, как раньше, а не сужает по компании.
|
||||
routeCompanyName={route.companyId ? companyName : undefined}
|
||||
api={api}
|
||||
route={route}
|
||||
requestType={requestType}
|
||||
|
||||
@@ -271,6 +271,18 @@ export const texts = {
|
||||
? 'Сейчас эту должность занимает:'
|
||||
: `Эту должность занимают ${count} ${pluralRu(count, ['сотрудник', 'сотрудника', 'сотрудников'])} — ` +
|
||||
'на согласование пойдёт тот, чья компания совпадёт с компанией инициатора:',
|
||||
// У маршрута задана конкретная компания (не запасной «любая компания») —
|
||||
// резолвиться будет только сотрудник этой компании, остальные найденные
|
||||
// (в других компаниях) этому маршруту не подойдут ни при каких условиях.
|
||||
positionPreviewFoundInCompany: (company: string, count: number): string =>
|
||||
count === 1
|
||||
? `Эту должность в компании «${company}» (компания маршрута) занимает:`
|
||||
: `Эту должность в компании «${company}» (компания маршрута) занимают ${count} ` +
|
||||
`${pluralRu(count, ['сотрудник', 'сотрудника', 'сотрудников'])}:`,
|
||||
positionPreviewWrongCompany: (company: string): string =>
|
||||
`Такая должность есть в справочнике, но не в компании «${company}» — а маршрут применяется именно к её ` +
|
||||
'инициаторам. При резолве шаг останется без исполнителя.',
|
||||
positionPreviewOtherCompanies: 'Есть в других компаниях:',
|
||||
positionPreviewNone:
|
||||
'Точно такой должности в справочнике нет — на резолве шаг останется без исполнителя. Проверьте написание.',
|
||||
positionPreviewSimilar: 'Похожие должности в справочнике (нажмите, чтобы подставить точное написание):',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user