Обходной лист (#12)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/12
This commit was merged in pull request #12.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import styles from './RouteStepFormModal.module.scss';
|
||||
import {
|
||||
ComparisonOperator,
|
||||
Employee,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
} from '../../models';
|
||||
import { comparisonOperatorLabels, headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts';
|
||||
import { detectExecutorKind } from '../../utils/stepExecutor';
|
||||
import { toEmployeeOption } from '../../utils/employee';
|
||||
import { matchEmployeesByPosition, toEmployeeOption } from '../../utils/employee';
|
||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||
import { useSharePointFields } from '../../hooks/useSharePointFields';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
@@ -31,6 +32,15 @@ import {
|
||||
export interface IRouteStepFormModalProps {
|
||||
api: ApprovalRouteApi;
|
||||
route: TemplateRoute;
|
||||
/**
|
||||
* Название компании маршрута, если она задана (route.companyId) — undefined
|
||||
* для запасного маршрута («любая компания»). Резолв шага «по должности»
|
||||
* ищет исполнителя ИМЕННО в этой компании (initiatorCompany = компания
|
||||
* инициатора, а маршрут с заданной компанией применяется только к таким
|
||||
* инициаторам), поэтому подсказка должности должна сужаться под неё же —
|
||||
* иначе показала бы человека, которого этот маршрут никогда не найдёт.
|
||||
*/
|
||||
routeCompanyName?: string;
|
||||
/** Undefined, пока грузится тип заявки маршрута. */
|
||||
requestType?: RequestType;
|
||||
sharePoint: SharePointListsClient;
|
||||
@@ -76,6 +86,20 @@ const OPERATION_OPTIONS = Object.values(ComparisonOperator)
|
||||
.filter(v => v !== ComparisonOperator.None)
|
||||
.map(v => ({ value: v, label: comparisonOperatorLabels[v] }));
|
||||
|
||||
/**
|
||||
* Служебное поле условия, не привязанное к списку SharePoint — вычисляется на бэкенде
|
||||
* (см. TemplateRouteService.ResolveAsync) по значению поля Position В САМОЙ ЗАЯВКЕ
|
||||
* («Должность» — например, у увольняемого в обходном листе), а не по должности того,
|
||||
* кто отправил заявку. Поэтому поле доступно всегда, даже если у типа заявки не
|
||||
* настроен list_name — источник данных не сам список, а fieldValues resolve-запроса.
|
||||
*/
|
||||
const IS_MANAGER_FIELD = 'IsManager';
|
||||
const SYNTHETIC_FIELD_OPTIONS = [{ value: IS_MANAGER_FIELD, label: texts.routeSteps.conditionFieldIsManagerLabel }];
|
||||
const IS_MANAGER_VALUE_OPTIONS = [
|
||||
{ value: 'true', label: texts.common.yes },
|
||||
{ value: 'false', label: texts.common.no }
|
||||
];
|
||||
|
||||
function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState {
|
||||
if (!editing) {
|
||||
return {
|
||||
@@ -117,6 +141,7 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
|
||||
export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
api,
|
||||
route,
|
||||
routeCompanyName,
|
||||
requestType,
|
||||
sharePoint,
|
||||
editing,
|
||||
@@ -132,18 +157,23 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined);
|
||||
|
||||
const fieldNameOptions = React.useMemo(() => {
|
||||
const options = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
|
||||
const listOptions = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
|
||||
const options = [...SYNTHETIC_FIELD_OPTIONS, ...listOptions];
|
||||
if (form.fieldName && !options.some(o => o.value === form.fieldName)) {
|
||||
options.unshift({ value: form.fieldName, label: form.fieldName });
|
||||
options.push({ value: form.fieldName, label: form.fieldName });
|
||||
}
|
||||
return options;
|
||||
}, [sharePointFields.items, form.fieldName]);
|
||||
|
||||
const isManagerField = form.fieldName === IS_MANAGER_FIELD;
|
||||
|
||||
// Тип столбца известен, только если он есть среди подгруженных полей списка —
|
||||
// для значения, введённого до появления поля в списке (или после его удаления),
|
||||
// ограничения ниже не действуют, чтобы не блокировать уже сохранённые данные.
|
||||
// IsManager — служебное булево поле, а не колонка списка, поэтому для него
|
||||
// числовые ограничения не действуют в любом случае.
|
||||
const selectedField = sharePointFields.items.find(f => f.internalName === form.fieldName);
|
||||
const isNumericField = selectedField ? isNumericFieldType(selectedField.typeAsString) : true;
|
||||
const isNumericField = isManagerField ? false : selectedField ? isNumericFieldType(selectedField.typeAsString) : true;
|
||||
|
||||
const operationOptions = isNumericField
|
||||
? OPERATION_OPTIONS
|
||||
@@ -158,6 +188,121 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isNumericField]);
|
||||
|
||||
// IsManager — булево поле: значение должно быть одним из IS_MANAGER_VALUE_OPTIONS,
|
||||
// а не произвольным текстом, который пользователь мог набрать для другого field_name.
|
||||
React.useEffect(() => {
|
||||
if (isManagerField && form.value !== 'true' && form.value !== 'false') {
|
||||
setForm(f => ({ ...f, value: 'true' }));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isManagerField]);
|
||||
|
||||
// Подсказка «кого найдёт эта должность»: бэкенд на резолве ищет ТОЧНОЕ
|
||||
// совпадение должности в справочнике (/Employee/ByPosition), и опечатка тихо
|
||||
// оставляет шаг без исполнителя — до самого резолва об этом узнать негде.
|
||||
// Считаем по уже загруженному справочнику сотрудников, без запросов на ввод.
|
||||
const positionMatches = React.useMemo(
|
||||
() => matchEmployeesByPosition(employees, form.position),
|
||||
[employees, form.position]
|
||||
);
|
||||
|
||||
const positionPreview = ((): React.ReactNode => {
|
||||
if (!form.position.trim()) return undefined;
|
||||
if (employeesLoading) {
|
||||
return <div className={styles.positionPreview}>{texts.routeSteps.positionPreviewLoading}</div>;
|
||||
}
|
||||
// Справочник не приехал (нет клиента/ошибка) — молчим о «не найдено»: это
|
||||
// сказало бы неправду про корректную должность.
|
||||
if (employees.length === 0) {
|
||||
return <div className={styles.positionPreview}>{texts.routeSteps.positionPreviewNoDictionary}</div>;
|
||||
}
|
||||
|
||||
if (positionMatches.exact.length > 0) {
|
||||
// У маршрута задана конкретная компания — резолв ищет исполнителя ИМЕННО
|
||||
// в ней (route.companyId сужает круг инициаторов до этой компании, а
|
||||
// бэкенд ищет по компании инициатора). Найденные в других компаниях
|
||||
// этому маршруту не подойдут — их надо не прятать, а объяснить, что они не в счёт.
|
||||
const inRouteCompany = routeCompanyName
|
||||
? positionMatches.exact.filter(e => e.companies.indexOf(routeCompanyName) !== -1)
|
||||
: 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 => {
|
||||
// Показываем ИМЕННО ту компанию, по которой сотрудник совпал с
|
||||
// маршрутом — она не обязана быть его company (первой из
|
||||
// справочника), если он числится в нескольких (см. companies).
|
||||
const shownCompany = routeCompanyName ?? e.company;
|
||||
return (
|
||||
<li key={e.email}>
|
||||
<span className={styles.previewPerson}>{e.fio}</span>
|
||||
{shownCompany && <span className={styles.previewCompany}> · {shownCompany}</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}>
|
||||
<span className={styles.previewPerson}>{e.fio}</span>
|
||||
{e.company && <span className={styles.previewCompany}> · {e.company}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.positionPreview} ${styles.previewMiss}`}>
|
||||
<span className={styles.previewTitle}>{texts.routeSteps.positionPreviewNone}</span>
|
||||
{positionMatches.similar.length > 0 && (
|
||||
<>
|
||||
<div className={styles.previewTitle}>{texts.routeSteps.positionPreviewSimilar}</div>
|
||||
<ul className={styles.previewList}>
|
||||
{positionMatches.similar.map(s => (
|
||||
<li key={s.position}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.previewSuggest}
|
||||
onClick={() => setForm(f => ({ ...f, position: s.position }))}
|
||||
>
|
||||
{s.position} — {s.fio}
|
||||
{s.count > 1 && (
|
||||
<span className={styles.previewSuggestCount}>
|
||||
{' '}
|
||||
({texts.routeSteps.positionPreviewMore(s.count - 1)})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})();
|
||||
|
||||
const submitForm = (): void => {
|
||||
const orderNo = Number(form.orderNo);
|
||||
if (!orderNo || orderNo < 1) {
|
||||
@@ -276,6 +421,7 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
onChange={e => setForm({ ...form, position: e.target.value })}
|
||||
placeholder={texts.routeSteps.positionPlaceholder}
|
||||
/>
|
||||
{positionPreview}
|
||||
</Field>
|
||||
)}
|
||||
{form.kind === 'projectRole' && (
|
||||
@@ -326,12 +472,11 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
<Checkbox
|
||||
label={texts.routeSteps.conditionCheckbox}
|
||||
checked={form.hasCondition}
|
||||
disabled={!hasListId}
|
||||
onChange={v => setForm({ ...form, hasCondition: v })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{form.hasCondition && hasListId && (
|
||||
{form.hasCondition && (
|
||||
<>
|
||||
<Field
|
||||
label={texts.routeSteps.conditionFieldNameLabel}
|
||||
@@ -361,12 +506,20 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.routeSteps.conditionValueLabel} required>
|
||||
<TextInput
|
||||
type={isNumericField ? 'number' : 'text'}
|
||||
value={form.value}
|
||||
onChange={e => setForm({ ...form, value: e.target.value })}
|
||||
placeholder={texts.routeSteps.conditionValuePlaceholder}
|
||||
/>
|
||||
{isManagerField ? (
|
||||
<Select
|
||||
options={IS_MANAGER_VALUE_OPTIONS}
|
||||
value={form.value}
|
||||
onChange={e => setForm({ ...form, value: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
type={isNumericField ? 'number' : 'text'}
|
||||
value={form.value}
|
||||
onChange={e => setForm({ ...form, value: e.target.value })}
|
||||
placeholder={texts.routeSteps.conditionValuePlaceholder}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user