разные правки
This commit is contained in:
@@ -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 (
|
return (
|
||||||
<div className={`${styles.positionPreview} ${styles.previewOk}`}>
|
<div className={`${styles.positionPreview} ${styles.previewOk}`}>
|
||||||
<span className={styles.previewTitle}>
|
<span className={styles.previewTitle}>
|
||||||
{texts.routeSteps.positionPreviewFound(positionMatches.exact.length)}
|
{routeCompanyName
|
||||||
|
? texts.routeSteps.positionPreviewFoundInCompany(routeCompanyName, inRouteCompany.length)
|
||||||
|
: texts.routeSteps.positionPreviewFound(inRouteCompany.length)}
|
||||||
</span>
|
</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}>
|
<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' });
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user