каркас вроде есть
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
import * as React from 'react';
|
||||
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 {
|
||||
ActionsCell,
|
||||
Alert,
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Checkbox,
|
||||
ConfirmDialog,
|
||||
DataTable,
|
||||
Field,
|
||||
FormGrid,
|
||||
IconButton,
|
||||
Modal,
|
||||
PageHeader,
|
||||
PrimaryCell,
|
||||
RadioGroup,
|
||||
Select,
|
||||
TextInput
|
||||
} from '../ui';
|
||||
|
||||
export interface IRouteStepsScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
route: TemplateRoute;
|
||||
requestTypeName: string;
|
||||
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 [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 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 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);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Главная' },
|
||||
{ label: 'Маршруты согласования', onClick: onBack },
|
||||
{ label: `Шаги маршрута #${route.id}` }
|
||||
]}
|
||||
/>
|
||||
<PageHeader
|
||||
icon="flag-check"
|
||||
subtitle={`${requestTypeName} · ${routeTypeLabels[route.routeType]}`}
|
||||
title={`Маршрут #${route.id}`}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="ghost" icon="arrow-left" onClick={onBack}>К маршрутам</Button>
|
||||
<Button variant="accent" icon="plus" onClick={openCreate}>Добавить шаг</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{warning && <Alert kind="warn">{warning}</Alert>}
|
||||
{loadError && <Alert kind="err">{loadError}</Alert>}
|
||||
{deleteError && <Alert kind="err">{deleteError}</Alert>}
|
||||
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'order', header: '№', width: '50px', render: r => r.orderNo },
|
||||
{
|
||||
key: 'executor',
|
||||
header: 'Исполнитель',
|
||||
render: r => <PrimaryCell>{describeExecutor(r)}</PrimaryCell>
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Статус',
|
||||
render: r => (
|
||||
<Badge variant={r.status === RouteStepStatus.Approval ? 'gold' : 'blue'}>
|
||||
{routeStepStatusLabels[r.status]}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'reassign',
|
||||
header: 'Переназначение',
|
||||
align: 'center',
|
||||
render: r => (r.allowReassignToOtherApprovers ? <Badge variant="ok">Разрешено</Badge> : <Badge variant="neutral">Нет</Badge>)
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
width: '100px',
|
||||
align: 'right',
|
||||
render: r => (
|
||||
<ActionsCell>
|
||||
<IconButton icon="edit" label="Редактировать" onClick={() => openEdit(r)} />
|
||||
<IconButton icon="trash" label="Удалить" danger onClick={() => { setDeleteError(undefined); setDeleting(r); }} />
|
||||
</ActionsCell>
|
||||
)
|
||||
}
|
||||
]}
|
||||
rows={sorted}
|
||||
keyOf={r => r.id}
|
||||
emptyMessage={loading ? 'Загрузка...' : 'Шагов пока нет — добавьте первый.'}
|
||||
/>
|
||||
|
||||
{showForm && (
|
||||
<Modal
|
||||
title={editing ? `Шаг #${editing.orderNo}` : 'Новый шаг маршрута'}
|
||||
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 && (
|
||||
<ConfirmDialog
|
||||
title="Удалить шаг?"
|
||||
text={`Шаг #${deleting.orderNo} будет удалён безвозвратно.`}
|
||||
confirmLabel="Удалить"
|
||||
busy={deleteBusy}
|
||||
onCancel={() => setDeleting(undefined)}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
)}
|
||||
</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 '—';
|
||||
}
|
||||
Reference in New Issue
Block a user