Фронтенд для сервиса (#5)
Tests / test (push) Successful in 2m26s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/5
This commit was merged in pull request #5.
This commit is contained in:
2026-07-30 06:11:24 +00:00
parent 5476c1bab5
commit da158cfd84
114 changed files with 26045 additions and 2 deletions
@@ -0,0 +1,188 @@
import * as React from 'react';
import { Company, Department, RequestType, RouteType, TemplateRoute } from '../../models';
import { routeTypeLabels, texts } from '../../texts';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { ApprovalRouteApi } from '../../services/api';
import { Checkbox, Field, FormGrid, FormModal, SearchableSelect, Select } from '../ui';
export interface IRouteFormModalProps {
api: ApprovalRouteApi;
/** Undefined — создание нового маршрута, иначе редактирование существующего. */
editing?: TemplateRoute;
/** Тип заявки, под которым открыли форму создания (например, из активного фильтра списка). */
initialRequestTypeId: string;
requestTypes: RequestType[];
companies: Company[];
departments: Department[];
dictionaryLoading: boolean;
onSaved: () => void;
onClose: () => void;
}
interface IFormState {
requestTypeId: string;
routeType: RouteType;
companyId: string;
departmentId: string;
byProject: boolean;
requiresControl: boolean;
}
const ROUTE_TYPE_OPTIONS = Object.values(RouteType)
.filter(v => v !== RouteType.None)
.map(v => ({ value: v, label: routeTypeLabels[v] }));
function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: string): IFormState {
if (!editing) {
return {
requestTypeId: initialRequestTypeId,
routeType: RouteType.Sequential,
companyId: '',
departmentId: '',
byProject: false,
requiresControl: false
};
}
return {
requestTypeId: String(editing.requestTypeId),
routeType: editing.routeType,
companyId: editing.companyId,
departmentId: editing.departmentId ?? '',
byProject: editing.byProject,
requiresControl: editing.requiresControl
};
}
/** Форма создания/редактирования маршрута — вынесена из RoutesScreen, чтобы тот не разрастался. */
export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
api,
editing,
initialRequestTypeId,
requestTypes,
companies,
departments,
dictionaryLoading,
onSaved,
onClose
}) => {
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, initialRequestTypeId));
const submit = useAsyncSubmit();
// Отдел принадлежит компании, поэтому список сужаем под выбранную:
// иначе пользователь листает ~700 позиций из всех юрлиц сразу.
const departmentsForSelectedCompany = React.useMemo(
() => (form.companyId ? departments.filter(d => d.companyId === form.companyId) : []),
[departments, form.companyId]
);
const submitForm = (): void => {
if (!editing && !form.requestTypeId) {
submit.fail(texts.routes.validationRequestType);
return;
}
if (!form.companyId.trim()) {
submit.fail(texts.routes.validationCompany);
return;
}
const departmentId = form.departmentId.trim() ? form.departmentId.trim() : null;
const request = editing
? api.templateRoutes.update(editing.id, {
routeType: form.routeType,
companyId: form.companyId.trim(),
departmentId,
byProject: form.byProject,
requiresControl: form.requiresControl
})
: api.templateRoutes.create({
routeType: form.routeType,
requestTypeId: Number(form.requestTypeId),
companyId: form.companyId.trim(),
departmentId,
byProject: form.byProject,
requiresControl: form.requiresControl
});
submit.run(() =>
request.then(() => {
onSaved();
onClose();
})
);
};
return (
<FormModal
title={editing ? texts.routes.editTitle(editing.id) : texts.routes.createTitle}
error={submit.error}
isSaving={submit.isBusy}
onSubmit={submitForm}
onClose={onClose}
>
<FormGrid>
<Field label={texts.routes.requestTypeLabel} required full>
<Select
options={requestTypes.map(rt => ({ value: String(rt.id), label: rt.name }))}
placeholder={texts.routes.requestTypePlaceholder}
value={form.requestTypeId}
disabled={!!editing}
onChange={e => setForm({ ...form, requestTypeId: e.target.value })}
/>
</Field>
<Field label={texts.routes.routeTypeLabel} required full>
<Select
options={ROUTE_TYPE_OPTIONS}
value={form.routeType}
onChange={e => setForm({ ...form, routeType: e.target.value as RouteType })}
/>
</Field>
<Field label={texts.routes.companyLabel} required full hint={texts.routes.companyHint}>
<SearchableSelect
options={companies.map(c => ({ value: c.id, label: c.name }))}
value={form.companyId}
loading={dictionaryLoading}
placeholder={texts.routes.companyPlaceholder}
searchPlaceholder={texts.routes.companySearchPlaceholder}
onChange={companyId =>
// Смена компании обнуляет отдел: прежний принадлежал другой
// компании, и бэкенд не нашёл бы такой маршрут при resolve.
setForm({ ...form, companyId, departmentId: '' })
}
/>
</Field>
<Field
label={texts.routes.departmentLabel}
full
hint={form.companyId ? texts.routes.departmentHint : texts.routes.departmentHintNoCompany}
>
<SearchableSelect
options={departmentsForSelectedCompany.map(d => ({ value: d.id, label: d.name }))}
value={form.departmentId}
loading={dictionaryLoading}
disabled={!form.companyId}
allowClear
clearLabel={texts.routes.departmentPlaceholder}
placeholder={texts.routes.departmentPlaceholder}
searchPlaceholder={texts.routes.departmentSearchPlaceholder}
emptyMessage={texts.routes.departmentEmpty}
onChange={departmentId => setForm({ ...form, departmentId })}
/>
</Field>
<Field label={texts.routes.byProjectLabel}>
<Checkbox
label={texts.routes.byProjectCheckbox}
checked={form.byProject}
onChange={v => setForm({ ...form, byProject: v })}
/>
</Field>
<Field label={texts.routes.controlLabel}>
<Checkbox
label={texts.routes.controlCheckbox}
checked={form.requiresControl}
onChange={v => setForm({ ...form, requiresControl: v })}
/>
</Field>
</FormGrid>
</FormModal>
);
};