рефакторинг

This commit is contained in:
gorbenko.pavel
2026-07-29 17:08:52 +05:00
parent 46552a1381
commit df3558b63a
61 changed files with 1743 additions and 1189 deletions
@@ -1,37 +1,22 @@
import * as React from 'react';
import { RouteStepFormModal } from './RouteStepFormModal';
import { ApprovalRouteApi } from '../../services/api';
import { ApiClientError } from '../../services/httpClient';
import {
HeadType,
headTypeLabels,
ProjectRole,
projectRoleLabels,
RouteStepStatus,
routeStepStatusLabels,
routeTypeLabels,
StepExecutorKind,
TemplateRoute,
TemplateRouteStep,
detectExecutorKind
} from '../../models';
import { RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models';
import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts';
import { describeStepExecutor } from '../../utils/stepExecutor';
import { useRouteSteps } from '../../hooks/useRouteSteps';
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import {
ActionsCell,
Alert,
Badge,
Breadcrumbs,
Button,
Checkbox,
ConfirmDialog,
DataTable,
Field,
FormGrid,
IconButton,
Modal,
PageHeader,
PrimaryCell,
RadioGroup,
Select,
TextInput
PrimaryCell
} from '../ui';
export interface IRouteStepsScreenProps {
@@ -41,186 +26,71 @@ export interface IRouteStepsScreenProps {
onBack: () => void;
}
interface IFormState {
orderNo: string;
status: RouteStepStatus;
kind: StepExecutorKind;
executorEmail: string;
position: string;
projectRole: ProjectRole;
headType: HeadType;
allowReassignToOtherApprovers: boolean;
}
function emptyForm(nextOrderNo: number): IFormState {
return {
orderNo: String(nextOrderNo),
status: RouteStepStatus.Approval,
kind: 'email',
executorEmail: '',
position: '',
projectRole: ProjectRole.ProjectManager,
headType: HeadType.Chief,
allowReassignToOtherApprovers: false
};
}
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] }));
export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route, requestTypeName, onBack }) => {
const [items, setItems] = React.useState<TemplateRouteStep[]>([]);
const [loading, setLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState<string | undefined>();
const [warning, setWarning] = React.useState<string | undefined>();
const steps = useRouteSteps(api, route.id);
const [savedWarning, setSavedWarning] = React.useState<string | undefined>();
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
const [showForm, setShowForm] = React.useState(false);
const [form, setForm] = React.useState<IFormState>(emptyForm(1));
const [formError, setFormError] = React.useState<string | undefined>();
const [saving, setSaving] = React.useState(false);
const [deleting, setDeleting] = React.useState<TemplateRouteStep | undefined>();
const [deleteError, setDeleteError] = React.useState<string | undefined>();
const [deleteBusy, setDeleteBusy] = React.useState(false);
const load = React.useCallback(() => {
setLoading(true);
setLoadError(undefined);
api.templateRouteSteps
.list(route.id)
.then(setItems)
.catch((e: ApiClientError) => setLoadError(e.message))
.finally(() => setLoading(false));
}, [api, route.id]);
React.useEffect(() => { load(); }, [load]);
const deleteTarget = useDeleteTarget<TemplateRouteStep>(
item => api.templateRouteSteps.remove(route.id, item.id),
steps.reload
);
const openCreate = (): void => {
setEditing(undefined);
const nextOrder = items.length > 0 ? Math.max(...items.map(s => s.orderNo)) + 1 : 1;
setForm(emptyForm(nextOrder));
setFormError(undefined);
setShowForm(true);
};
const openEdit = (item: TemplateRouteStep): void => {
setEditing(item);
setForm({
orderNo: String(item.orderNo),
status: item.status,
kind: detectExecutorKind(item),
executorEmail: item.executorEmail ?? '',
position: item.position ?? '',
projectRole: item.projectRole && item.projectRole !== ProjectRole.None ? item.projectRole : ProjectRole.ProjectManager,
headType: item.headType && item.headType !== HeadType.None ? item.headType : HeadType.Chief,
allowReassignToOtherApprovers: item.allowReassignToOtherApprovers
});
setFormError(undefined);
setShowForm(true);
};
const submit = (): void => {
const orderNo = Number(form.orderNo);
if (!orderNo || orderNo < 1) {
setFormError('Порядковый номер шага обязателен.');
return;
}
if (form.kind === 'email' && !form.executorEmail.trim()) {
setFormError('Укажите email исполнителя.');
return;
}
if (form.kind === 'position' && !form.position.trim()) {
setFormError('Укажите должность.');
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
};
setSaving(true);
setFormError(undefined);
const request = editing
? api.templateRouteSteps.update(route.id, editing.id, payload)
: api.templateRouteSteps.create(route.id, payload);
request
.then(step => {
setShowForm(false);
setWarning(step.warning ?? undefined);
load();
})
.catch((e: ApiClientError) => setFormError(e.message))
.finally(() => setSaving(false));
const handleSaved = (savedStep: TemplateRouteStep): void => {
setSavedWarning(savedStep.warning ?? undefined);
steps.reload();
};
const confirmDelete = (): void => {
if (!deleting) return;
setDeleteBusy(true);
setDeleteError(undefined);
api.templateRouteSteps
.remove(route.id, deleting.id)
.then(() => {
setDeleting(undefined);
load();
})
.catch((e: ApiClientError) => setDeleteError(e.message))
.finally(() => setDeleteBusy(false));
};
const sorted = [...items].sort((a, b) => a.orderNo - b.orderNo);
const nextOrderNo = steps.items.length > 0 ? Math.max(...steps.items.map(s => s.orderNo)) + 1 : 1;
return (
<div>
<Breadcrumbs
items={[
{ label: 'Главная' },
{ label: 'Маршруты согласования', onClick: onBack },
{ label: `Шаги маршрута #${route.id}` }
{ label: texts.common.home },
{ label: texts.routes.breadcrumb, onClick: onBack },
{ label: texts.routeSteps.breadcrumb(route.id) }
]}
/>
<PageHeader
icon="flag-check"
subtitle={`${requestTypeName} · ${routeTypeLabels[route.routeType]}`}
title={`Маршрут #${route.id}`}
subtitle={texts.routeSteps.subtitle(requestTypeName, routeTypeLabels[route.routeType])}
title={texts.routeSteps.title(route.id)}
actions={
<>
<Button variant="ghost" icon="arrow-left" onClick={onBack}>К маршрутам</Button>
<Button variant="accent" icon="plus" onClick={openCreate}>Добавить шаг</Button>
<Button variant="ghost" icon="arrow-left" onClick={onBack}>{texts.routeSteps.back}</Button>
<Button variant="accent" icon="plus" onClick={openCreate}>{texts.routeSteps.add}</Button>
</>
}
/>
{warning && <Alert kind="warn">{warning}</Alert>}
{loadError && <Alert kind="err">{loadError}</Alert>}
{deleteError && <Alert kind="err">{deleteError}</Alert>}
{savedWarning && <Alert kind="warn">{savedWarning}</Alert>}
{steps.error && <Alert kind="err">{steps.error}</Alert>}
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
<DataTable
columns={[
{ key: 'order', header: '№', width: '50px', render: r => r.orderNo },
{ key: 'order', header: texts.routeSteps.columnOrderNo, width: '50px', render: r => r.orderNo },
{
key: 'executor',
header: 'Исполнитель',
render: r => <PrimaryCell>{describeExecutor(r)}</PrimaryCell>
header: texts.routeSteps.columnExecutor,
render: r => <PrimaryCell>{describeStepExecutor(r)}</PrimaryCell>
},
{
key: 'status',
header: 'Статус',
header: texts.routeSteps.columnStatus,
render: r => (
<Badge variant={r.status === RouteStepStatus.Approval ? 'gold' : 'blue'}>
{routeStepStatusLabels[r.status]}
@@ -229,9 +99,9 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
},
{
key: 'reassign',
header: 'Переназначение',
header: texts.routeSteps.columnReassign,
align: 'center',
render: r => (r.allowReassignToOtherApprovers ? <Badge variant="ok">Разрешено</Badge> : <Badge variant="neutral">Нет</Badge>)
render: r => (r.allowReassignToOtherApprovers ? <Badge variant="ok">{texts.routeSteps.reassignAllowed}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>)
},
{
key: 'actions',
@@ -241,133 +111,38 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
sticky: true,
render: r => (
<ActionsCell>
<IconButton icon="edit" label="Редактировать" onClick={() => openEdit(r)} />
<IconButton icon="trash" label="Удалить" danger onClick={() => { setDeleteError(undefined); setDeleting(r); }} />
<IconButton icon="edit" label={texts.common.edit} onClick={() => openEdit(r)} />
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
</ActionsCell>
)
}
]}
rows={sorted}
rows={steps.items}
keyOf={r => r.id}
emptyMessage={loading ? 'Загрузка...' : 'Шагов пока нет — добавьте первый.'}
emptyMessage={steps.isLoading ? texts.common.loading : texts.routeSteps.emptyList}
/>
{showForm && (
<Modal
title={editing ? `Шаг #${editing.orderNo}` : 'Новый шаг маршрута'}
<RouteStepFormModal
api={api}
route={route}
editing={editing}
nextOrderNo={nextOrderNo}
onSaved={handleSaved}
onClose={() => setShowForm(false)}
footer={
<>
<Button variant="secondary" onClick={() => setShowForm(false)} disabled={saving}>Отмена</Button>
<Button variant="accent" icon="check" onClick={submit} disabled={saving}>
{saving ? 'Сохранение...' : 'Сохранить'}
</Button>
</>
}
>
{formError && <Alert kind="err">{formError}</Alert>}
<FormGrid>
<Field label="Порядковый номер" required>
<TextInput
type="number"
min={1}
value={form.orderNo}
onChange={e => setForm({ ...form, orderNo: e.target.value })}
/>
</Field>
<Field label="Статус шага" required>
<Select
options={STATUS_OPTIONS}
value={form.status}
onChange={e => setForm({ ...form, status: e.target.value as RouteStepStatus })}
/>
</Field>
<Field label="Кто исполнитель" required full hint="Должно быть заполнено ровно одно из полей.">
<RadioGroup
name="executorKind"
value={form.kind}
onChange={kind => setForm({ ...form, kind })}
options={[
{ value: 'email', label: 'Конкретный сотрудник (email)' },
{ value: 'position', label: 'По должности в компании инициатора' },
{
value: 'projectRole',
label: 'По роли в проекте',
disabled: !route.byProject,
disabledHint: 'Недоступно: маршрут не учитывает проект (по проекту = нет).'
},
{ value: 'headType', label: 'По типу руководителя' }
]}
/>
</Field>
{form.kind === 'email' && (
<Field label="Email исполнителя" required full>
<TextInput
value={form.executorEmail}
onChange={e => setForm({ ...form, executorEmail: e.target.value })}
placeholder="user@tnsu.kz"
/>
</Field>
)}
{form.kind === 'position' && (
<Field label="Должность" required full hint="Ищется в справочнике api.tnsu.kz по компании инициатора.">
<TextInput
value={form.position}
onChange={e => setForm({ ...form, position: e.target.value })}
placeholder="Главный инженер"
/>
</Field>
)}
{form.kind === 'projectRole' && (
<Field label="Роль в проекте" 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="Тип руководителя" required full>
<Select
options={HEAD_TYPE_OPTIONS}
value={form.headType}
onChange={e => setForm({ ...form, headType: e.target.value as HeadType })}
/>
</Field>
)}
<Field label="Переназначение" full>
<Checkbox
label="Разрешить переназначать другим согласующим"
checked={form.allowReassignToOtherApprovers}
onChange={v => setForm({ ...form, allowReassignToOtherApprovers: v })}
/>
</Field>
</FormGrid>
</Modal>
/>
)}
{deleting && (
{deleteTarget.target && (
<ConfirmDialog
title="Удалить шаг?"
text={`Шаг #${deleting.orderNo} будет удалён безвозвратно.`}
confirmLabel="Удалить"
busy={deleteBusy}
onCancel={() => setDeleting(undefined)}
onConfirm={confirmDelete}
title={texts.routeSteps.deleteTitle}
text={texts.routeSteps.deleteText(deleteTarget.target.orderNo)}
confirmLabel={texts.common.remove}
busy={deleteTarget.isBusy}
onCancel={deleteTarget.cancel}
onConfirm={deleteTarget.confirm}
/>
)}
</div>
);
};
function describeExecutor(step: TemplateRouteStep): string {
if (step.executorEmail) return step.executorEmail;
if (step.position) return step.position;
if (step.projectRole && step.projectRole !== ProjectRole.None) return `Роль: ${projectRoleLabels[step.projectRole]}`;
if (step.headType && step.headType !== HeadType.None) return `Руководитель: ${headTypeLabels[step.headType]}`;
return '—';
}