Files
tnsu-approval-route/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx
T
2026-08-14 14:03:45 +05:00

483 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};