483 lines
20 KiB
TypeScript
483 lines
20 KiB
TypeScript
import * as React from 'react';
|
||
import styles from './RouteStepFormModal.module.scss';
|
||
import {
|
||
ComparisonOperator,
|
||
Employee,
|
||
HeadType,
|
||
ProjectRole,
|
||
RequestType,
|
||
RouteStepStatus,
|
||
StepExecutorKind,
|
||
TemplateRoute,
|
||
TemplateRouteStep
|
||
} from '../../models';
|
||
import { comparisonOperatorLabels, headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts';
|
||
import { detectExecutorKind } from '../../utils/stepExecutor';
|
||
import { matchEmployeesByPosition, toEmployeeOption } from '../../utils/employee';
|
||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||
import { useSharePointFields } from '../../hooks/useSharePointFields';
|
||
import { ApprovalRouteApi } from '../../services/api';
|
||
import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
|
||
import { Checkbox, Field, FormGrid, FormModal, RadioGroup, SearchableSelect, Select, TextInput } from '../ui';
|
||
import {
|
||
boolToTriState,
|
||
isValidSlaHours,
|
||
parseSlaHours,
|
||
slaHoursToInput,
|
||
triStateToBool,
|
||
TriState,
|
||
TRI_STATE_OPTIONS
|
||
} from '../../utils/sla';
|
||
|
||
export interface IRouteStepFormModalProps {
|
||
api: ApprovalRouteApi;
|
||
route: TemplateRoute;
|
||
/** Undefined, пока грузится тип заявки маршрута. */
|
||
requestType?: RequestType;
|
||
sharePoint: SharePointListsClient;
|
||
/** Undefined — создание нового шага, иначе редактирование существующего. */
|
||
editing?: TemplateRouteStep;
|
||
nextOrderNo: number;
|
||
employees: Employee[];
|
||
employeesLoading: boolean;
|
||
onSaved: (savedStep: TemplateRouteStep) => void;
|
||
onClose: () => void;
|
||
}
|
||
|
||
interface IFormState {
|
||
orderNo: string;
|
||
status: RouteStepStatus;
|
||
kind: StepExecutorKind;
|
||
executorEmail: string;
|
||
position: string;
|
||
projectRole: ProjectRole;
|
||
headType: HeadType;
|
||
allowReassignToOtherApprovers: boolean;
|
||
hasCondition: boolean;
|
||
fieldName: string;
|
||
operation: ComparisonOperator;
|
||
value: string;
|
||
/** Пусто — наследовать SLA от типа заявки; «0» — напоминать сразу. Это разные вещи. */
|
||
slaHours: string;
|
||
askOverdueReason: TriState;
|
||
}
|
||
|
||
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
|
||
.map(v => ({ value: v, label: routeStepStatusLabels[v] }));
|
||
|
||
const PROJECT_ROLE_OPTIONS = Object.values(ProjectRole)
|
||
.filter(v => v !== ProjectRole.None)
|
||
.map(v => ({ value: v, label: projectRoleLabels[v] }));
|
||
|
||
const HEAD_TYPE_OPTIONS = Object.values(HeadType)
|
||
.filter(v => v !== HeadType.None)
|
||
.map(v => ({ value: v, label: headTypeLabels[v] }));
|
||
|
||
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 {
|
||
orderNo: String(nextOrderNo),
|
||
status: RouteStepStatus.Approval,
|
||
kind: 'email',
|
||
executorEmail: '',
|
||
position: '',
|
||
projectRole: ProjectRole.ProjectManager,
|
||
headType: HeadType.Chief,
|
||
allowReassignToOtherApprovers: false,
|
||
hasCondition: false,
|
||
fieldName: '',
|
||
operation: ComparisonOperator.GreaterThan,
|
||
value: '',
|
||
slaHours: '',
|
||
askOverdueReason: ''
|
||
};
|
||
}
|
||
return {
|
||
orderNo: String(editing.orderNo),
|
||
status: editing.status,
|
||
kind: detectExecutorKind(editing),
|
||
executorEmail: editing.executorEmail ?? '',
|
||
position: editing.position ?? '',
|
||
projectRole: editing.projectRole && editing.projectRole !== ProjectRole.None ? editing.projectRole : ProjectRole.ProjectManager,
|
||
headType: editing.headType && editing.headType !== HeadType.None ? editing.headType : HeadType.Chief,
|
||
allowReassignToOtherApprovers: editing.allowReassignToOtherApprovers,
|
||
hasCondition: Boolean(editing.fieldName),
|
||
fieldName: editing.fieldName ?? '',
|
||
operation: editing.operation && editing.operation !== ComparisonOperator.None ? editing.operation : ComparisonOperator.GreaterThan,
|
||
value: editing.value ?? '',
|
||
slaHours: slaHoursToInput(editing.slaHours),
|
||
askOverdueReason: boolToTriState(editing.askOverdueReason)
|
||
};
|
||
}
|
||
|
||
/** Форма создания/редактирования шага маршрута — вынесена из RouteStepsScreen. */
|
||
export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||
api,
|
||
route,
|
||
requestType,
|
||
sharePoint,
|
||
editing,
|
||
nextOrderNo,
|
||
employees,
|
||
employeesLoading,
|
||
onSaved,
|
||
onClose
|
||
}) => {
|
||
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo));
|
||
const submit = useAsyncSubmit();
|
||
const hasListId = Boolean(requestType?.listId);
|
||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined);
|
||
|
||
const fieldNameOptions = React.useMemo(() => {
|
||
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.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 = isManagerField ? false : selectedField ? isNumericFieldType(selectedField.typeAsString) : true;
|
||
|
||
const operationOptions = isNumericField
|
||
? OPERATION_OPTIONS
|
||
: OPERATION_OPTIONS.filter(o => o.value === ComparisonOperator.Equal);
|
||
|
||
// Строковая колонка допускает только «равно» — если пользователь сменил
|
||
// field_name на нечисловую колонку, а раньше был выбран «больше»/«меньше», сбрасываем.
|
||
React.useEffect(() => {
|
||
if (!isNumericField && form.operation !== ComparisonOperator.Equal) {
|
||
setForm(f => ({ ...f, operation: ComparisonOperator.Equal }));
|
||
}
|
||
// 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) {
|
||
return (
|
||
<div className={`${styles.positionPreview} ${styles.previewOk}`}>
|
||
<span className={styles.previewTitle}>
|
||
{texts.routeSteps.positionPreviewFound(positionMatches.exact.length)}
|
||
</span>
|
||
<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) {
|
||
submit.fail(texts.routeSteps.validationOrderNo);
|
||
return;
|
||
}
|
||
if (form.kind === 'email' && !form.executorEmail.trim()) {
|
||
submit.fail(texts.routeSteps.validationExecutor);
|
||
return;
|
||
}
|
||
if (form.kind === 'position' && !form.position.trim()) {
|
||
submit.fail(texts.routeSteps.validationPosition);
|
||
return;
|
||
}
|
||
if (form.hasCondition && (!form.fieldName.trim() || !form.value.trim())) {
|
||
submit.fail(texts.routeSteps.validationCondition);
|
||
return;
|
||
}
|
||
if (form.hasCondition && isNumericField && !Number.isFinite(Number(form.value.trim()))) {
|
||
submit.fail(texts.routeSteps.validationConditionNumber);
|
||
return;
|
||
}
|
||
if (!isValidSlaHours(form.slaHours)) {
|
||
submit.fail(texts.routeSteps.validationSlaHours);
|
||
return;
|
||
}
|
||
|
||
const payload = {
|
||
orderNo,
|
||
status: form.status,
|
||
allowReassignToOtherApprovers: form.allowReassignToOtherApprovers,
|
||
executorEmail: form.kind === 'email' ? form.executorEmail.trim() : null,
|
||
position: form.kind === 'position' ? form.position.trim() : null,
|
||
projectRole: form.kind === 'projectRole' ? form.projectRole : null,
|
||
headType: form.kind === 'headType' ? form.headType : null,
|
||
fieldName: form.hasCondition ? form.fieldName.trim() : null,
|
||
operation: form.hasCondition ? form.operation : null,
|
||
value: form.hasCondition ? form.value.trim() : null,
|
||
slaHours: parseSlaHours(form.slaHours),
|
||
askOverdueReason: triStateToBool(form.askOverdueReason)
|
||
};
|
||
|
||
const request = editing
|
||
? api.templateRouteSteps.update(route.id, editing.id, payload)
|
||
: api.templateRouteSteps.create(route.id, payload);
|
||
|
||
submit.run(() =>
|
||
request.then(step => {
|
||
onSaved(step);
|
||
onClose();
|
||
})
|
||
);
|
||
};
|
||
|
||
return (
|
||
<FormModal
|
||
title={editing ? texts.routeSteps.editTitle(editing.orderNo) : texts.routeSteps.createTitle}
|
||
error={submit.error}
|
||
isSaving={submit.isBusy}
|
||
onSubmit={submitForm}
|
||
onClose={onClose}
|
||
>
|
||
<FormGrid>
|
||
<Field label={texts.routeSteps.orderNoLabel} required>
|
||
<TextInput
|
||
type="number"
|
||
min={1}
|
||
value={form.orderNo}
|
||
onChange={e => setForm({ ...form, orderNo: e.target.value })}
|
||
/>
|
||
</Field>
|
||
<Field label={texts.routeSteps.statusLabel} required>
|
||
<Select
|
||
options={STATUS_OPTIONS}
|
||
value={form.status}
|
||
onChange={e => setForm({ ...form, status: e.target.value as RouteStepStatus })}
|
||
/>
|
||
</Field>
|
||
|
||
<Field label={texts.routeSteps.kindLabel} required full hint={texts.routeSteps.kindHint}>
|
||
<RadioGroup
|
||
name="executorKind"
|
||
value={form.kind}
|
||
onChange={kind => setForm({ ...form, kind })}
|
||
options={[
|
||
{ value: 'email', label: texts.routeSteps.kindEmail },
|
||
{ value: 'position', label: texts.routeSteps.kindPosition },
|
||
{
|
||
value: 'projectRole',
|
||
label: texts.routeSteps.kindProjectRole,
|
||
disabled: !route.byProject,
|
||
disabledHint: texts.routeSteps.kindProjectRoleDisabled
|
||
},
|
||
{ value: 'headType', label: texts.routeSteps.kindHeadType }
|
||
]}
|
||
/>
|
||
</Field>
|
||
|
||
{form.kind === 'email' && (
|
||
<Field label={texts.routeSteps.executorLabel} required full>
|
||
<SearchableSelect
|
||
options={employees.map(toEmployeeOption)}
|
||
value={form.executorEmail}
|
||
loading={employeesLoading}
|
||
placeholder={texts.routeSteps.executorPlaceholder}
|
||
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||
emptyMessage={texts.select.employeesEmpty}
|
||
onChange={executorEmail => setForm({ ...form, executorEmail })}
|
||
/>
|
||
</Field>
|
||
)}
|
||
{form.kind === 'position' && (
|
||
<Field label={texts.routeSteps.positionLabel} required full hint={texts.routeSteps.positionHint}>
|
||
<TextInput
|
||
value={form.position}
|
||
onChange={e => setForm({ ...form, position: e.target.value })}
|
||
placeholder={texts.routeSteps.positionPlaceholder}
|
||
/>
|
||
{positionPreview}
|
||
</Field>
|
||
)}
|
||
{form.kind === 'projectRole' && (
|
||
<Field label={texts.routeSteps.projectRoleLabel} required full>
|
||
<Select
|
||
options={PROJECT_ROLE_OPTIONS}
|
||
value={form.projectRole}
|
||
onChange={e => setForm({ ...form, projectRole: e.target.value as ProjectRole })}
|
||
/>
|
||
</Field>
|
||
)}
|
||
{form.kind === 'headType' && (
|
||
<Field label={texts.routeSteps.headTypeLabel} required full>
|
||
<Select
|
||
options={HEAD_TYPE_OPTIONS}
|
||
value={form.headType}
|
||
onChange={e => setForm({ ...form, headType: e.target.value as HeadType })}
|
||
/>
|
||
</Field>
|
||
)}
|
||
|
||
<Field label={texts.routeSteps.reassignLabel} full>
|
||
<Checkbox
|
||
label={texts.routeSteps.reassignCheckbox}
|
||
checked={form.allowReassignToOtherApprovers}
|
||
onChange={v => setForm({ ...form, allowReassignToOtherApprovers: v })}
|
||
/>
|
||
</Field>
|
||
|
||
<Field label={texts.routeSteps.slaHoursLabel} hint={texts.routeSteps.slaHoursHint}>
|
||
<TextInput
|
||
type="number"
|
||
min={0}
|
||
value={form.slaHours}
|
||
onChange={e => setForm({ ...form, slaHours: e.target.value })}
|
||
placeholder={texts.routeSteps.slaHoursPlaceholder}
|
||
/>
|
||
</Field>
|
||
<Field label={texts.routeSteps.askOverdueReasonLabel} hint={texts.routeSteps.askOverdueReasonHint}>
|
||
<Select
|
||
options={TRI_STATE_OPTIONS}
|
||
value={form.askOverdueReason}
|
||
onChange={e => setForm({ ...form, askOverdueReason: e.target.value as TriState })}
|
||
/>
|
||
</Field>
|
||
|
||
<Field label={texts.routeSteps.conditionLabel} full hint={!hasListId ? texts.routeSteps.conditionDisabledHint : undefined}>
|
||
<Checkbox
|
||
label={texts.routeSteps.conditionCheckbox}
|
||
checked={form.hasCondition}
|
||
onChange={v => setForm({ ...form, hasCondition: v })}
|
||
/>
|
||
</Field>
|
||
|
||
{form.hasCondition && (
|
||
<>
|
||
<Field
|
||
label={texts.routeSteps.conditionFieldNameLabel}
|
||
required
|
||
full
|
||
hint={sharePointFields.error ? texts.routeSteps.conditionFieldNameError(sharePointFields.error) : undefined}
|
||
>
|
||
<Select
|
||
options={fieldNameOptions}
|
||
value={form.fieldName}
|
||
disabled={sharePointFields.isLoading}
|
||
placeholder={
|
||
sharePointFields.isLoading
|
||
? texts.routeSteps.conditionFieldNameLoading
|
||
: fieldNameOptions.length === 0
|
||
? texts.routeSteps.conditionFieldNameEmpty
|
||
: texts.routeSteps.conditionFieldNamePlaceholder
|
||
}
|
||
onChange={e => setForm({ ...form, fieldName: e.target.value })}
|
||
/>
|
||
</Field>
|
||
<Field label={texts.routeSteps.conditionOperationLabel} required>
|
||
<Select
|
||
options={operationOptions}
|
||
value={form.operation}
|
||
onChange={e => setForm({ ...form, operation: e.target.value as ComparisonOperator })}
|
||
/>
|
||
</Field>
|
||
<Field label={texts.routeSteps.conditionValueLabel} required>
|
||
{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>
|
||
</>
|
||
)}
|
||
</FormGrid>
|
||
</FormModal>
|
||
);
|
||
};
|