рефакторинг
This commit is contained in:
@@ -1,24 +1,27 @@
|
||||
import * as React from 'react';
|
||||
import styles from './RoutesScreen.module.scss';
|
||||
import { RouteFormModal } from './RouteFormModal';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { ApiClientError } from '../../services/httpClient';
|
||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||
import { Company, Department, RequestType, routeTypeLabels, RouteType, TemplateRoute } from '../../models';
|
||||
import { RouteType, TemplateRoute } from '../../models';
|
||||
import { routeTypeLabels, texts } from '../../texts';
|
||||
import { shortGuid } from '../../utils/guid';
|
||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
|
||||
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||
import {
|
||||
ActionsCell,
|
||||
Alert,
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Checkbox,
|
||||
ConfirmDialog,
|
||||
DataTable,
|
||||
Field,
|
||||
FormGrid,
|
||||
IconButton,
|
||||
Modal,
|
||||
PageHeader,
|
||||
PrimaryCell,
|
||||
SearchableSelect,
|
||||
Select
|
||||
} from '../ui';
|
||||
|
||||
@@ -28,222 +31,85 @@ export interface IRoutesScreenProps {
|
||||
onOpenSteps: (route: TemplateRoute) => void;
|
||||
}
|
||||
|
||||
interface IFormState {
|
||||
requestTypeId: string;
|
||||
routeType: RouteType;
|
||||
companyId: string;
|
||||
departmentId: string;
|
||||
byProject: boolean;
|
||||
requiresControl: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: IFormState = {
|
||||
requestTypeId: '',
|
||||
routeType: RouteType.Sequential,
|
||||
companyId: '',
|
||||
departmentId: '',
|
||||
byProject: false,
|
||||
requiresControl: false
|
||||
};
|
||||
|
||||
const ROUTE_TYPE_OPTIONS = Object.values(RouteType)
|
||||
.filter(v => v !== RouteType.None)
|
||||
.map(v => ({ value: v, label: routeTypeLabels[v] }));
|
||||
|
||||
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, onOpenSteps }) => {
|
||||
const [requestTypes, setRequestTypes] = React.useState<RequestType[]>([]);
|
||||
const [companies, setCompanies] = React.useState<Company[]>([]);
|
||||
const [departments, setDepartments] = React.useState<Department[]>([]);
|
||||
const [dictLoading, setDictLoading] = React.useState(true);
|
||||
const [dictError, setDictError] = React.useState<string | undefined>();
|
||||
const [items, setItems] = React.useState<TemplateRoute[]>([]);
|
||||
const [filterRequestTypeId, setFilterRequestTypeId] = React.useState<string>('');
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [loadError, setLoadError] = React.useState<string | undefined>();
|
||||
const requestTypes = useRequestTypes(api);
|
||||
const portalDictionaries = usePortalDictionaries(dictionary);
|
||||
|
||||
const [filterRequestTypeId, setFilterRequestTypeId] = React.useState('');
|
||||
const routes = useTemplateRoutes(api, filterRequestTypeId ? Number(filterRequestTypeId) : undefined);
|
||||
|
||||
const [editing, setEditing] = React.useState<TemplateRoute | undefined>();
|
||||
const [showForm, setShowForm] = React.useState(false);
|
||||
const [form, setForm] = React.useState<IFormState>(EMPTY_FORM);
|
||||
const [formError, setFormError] = React.useState<string | undefined>();
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
|
||||
const [deleting, setDeleting] = React.useState<TemplateRoute | undefined>();
|
||||
const [deleteError, setDeleteError] = React.useState<string | undefined>();
|
||||
const [deleteBusy, setDeleteBusy] = React.useState(false);
|
||||
const deleteTarget = useDeleteTarget<TemplateRoute>(item => api.templateRoutes.remove(item.id), routes.reload);
|
||||
|
||||
React.useEffect(() => {
|
||||
api.requestTypes.list().then(setRequestTypes).catch(() => { /* показывается в общей ошибке ниже */ });
|
||||
}, [api]);
|
||||
|
||||
// Справочники 1С грузим один раз на экран: они статичны, а отделов ~700 —
|
||||
// поиск делаем на клиенте, без запроса на каждый символ.
|
||||
React.useEffect(() => {
|
||||
if (!dictionary) return;
|
||||
|
||||
setDictLoading(true);
|
||||
setDictError(undefined);
|
||||
Promise.all([dictionary.companies(), dictionary.departments()])
|
||||
.then(([loadedCompanies, loadedDepartments]) => {
|
||||
setCompanies(loadedCompanies);
|
||||
setDepartments(loadedDepartments);
|
||||
})
|
||||
.catch((e: ApiClientError) => setDictError(e.message))
|
||||
.finally(() => setDictLoading(false));
|
||||
}, [dictionary]);
|
||||
|
||||
const load = React.useCallback(() => {
|
||||
setLoading(true);
|
||||
setLoadError(undefined);
|
||||
const requestTypeId = filterRequestTypeId ? Number(filterRequestTypeId) : undefined;
|
||||
api.templateRoutes
|
||||
.list(requestTypeId)
|
||||
.then(setItems)
|
||||
.catch((e: ApiClientError) => setLoadError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [api, filterRequestTypeId]);
|
||||
|
||||
React.useEffect(() => { load(); }, [load]);
|
||||
|
||||
const requestTypeName = (id: number): string => requestTypes.find(rt => rt.id === id)?.name ?? `#${id}`;
|
||||
const requestTypeName = (id: number): string => requestTypes.items.find(rt => rt.id === id)?.name ?? `#${id}`;
|
||||
|
||||
// Пока справочник не загрузился (или маршрут ссылается на удалённую запись),
|
||||
// показываем усечённый Guid — чтобы строка таблицы не выглядела пустой.
|
||||
const companyName = (id: string): string => companies.find(c => c.id === id)?.name ?? shortGuid(id);
|
||||
|
||||
const departmentName = (id: string): string => departments.find(d => d.id === id)?.name ?? shortGuid(id);
|
||||
|
||||
// Отдел принадлежит компании, поэтому список сужаем под выбранную:
|
||||
// иначе пользователь листает ~700 позиций из всех юрлиц сразу.
|
||||
const departmentsForSelectedCompany = React.useMemo(
|
||||
() => (form.companyId ? departments.filter(d => d.companyId === form.companyId) : []),
|
||||
[departments, form.companyId]
|
||||
);
|
||||
const companyName = (id: string): string => portalDictionaries.companies.find(c => c.id === id)?.name ?? shortGuid(id);
|
||||
const departmentName = (id: string): string => portalDictionaries.departments.find(d => d.id === id)?.name ?? shortGuid(id);
|
||||
|
||||
const openCreate = (): void => {
|
||||
setEditing(undefined);
|
||||
setForm({ ...EMPTY_FORM, requestTypeId: filterRequestTypeId });
|
||||
setFormError(undefined);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const openEdit = (item: TemplateRoute): void => {
|
||||
setEditing(item);
|
||||
setForm({
|
||||
requestTypeId: String(item.requestTypeId),
|
||||
routeType: item.routeType,
|
||||
companyId: item.companyId,
|
||||
departmentId: item.departmentId ?? '',
|
||||
byProject: item.byProject,
|
||||
requiresControl: item.requiresControl
|
||||
});
|
||||
setFormError(undefined);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const submit = (): void => {
|
||||
if (!editing && !form.requestTypeId) {
|
||||
setFormError('Выберите тип заявки.');
|
||||
return;
|
||||
}
|
||||
if (!form.companyId.trim()) {
|
||||
setFormError('Компания обязательна (Guid из справочника api.tnsu.kz).');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setFormError(undefined);
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
request
|
||||
.then(() => {
|
||||
setShowForm(false);
|
||||
load();
|
||||
})
|
||||
.catch((e: ApiClientError) => setFormError(e.message))
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
const confirmDelete = (): void => {
|
||||
if (!deleting) return;
|
||||
setDeleteBusy(true);
|
||||
setDeleteError(undefined);
|
||||
api.templateRoutes
|
||||
.remove(deleting.id)
|
||||
.then(() => {
|
||||
setDeleting(undefined);
|
||||
load();
|
||||
})
|
||||
.catch((e: ApiClientError) => setDeleteError(e.message))
|
||||
.finally(() => setDeleteBusy(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumbs items={[{ label: 'Главная' }, { label: 'Маршруты согласования' }]} />
|
||||
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.routes.breadcrumb }]} />
|
||||
<PageHeader
|
||||
icon="route"
|
||||
subtitle="Настройка согласований"
|
||||
title="Маршруты согласования"
|
||||
actions={<Button variant="accent" icon="plus" onClick={openCreate}>Добавить маршрут</Button>}
|
||||
subtitle={texts.routes.subtitle}
|
||||
title={texts.routes.title}
|
||||
actions={<Button variant="accent" icon="plus" onClick={openCreate}>{texts.routes.add}</Button>}
|
||||
/>
|
||||
|
||||
<div style={{ marginBottom: 18, maxWidth: 320 }}>
|
||||
<Field label="Фильтр по типу заявки">
|
||||
<div className={styles.filter}>
|
||||
<Field label={texts.routes.filterLabel}>
|
||||
<Select
|
||||
options={requestTypes.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||
placeholder="Все типы заявок"
|
||||
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||
placeholder={texts.routes.filterAll}
|
||||
value={filterRequestTypeId}
|
||||
onChange={e => setFilterRequestTypeId(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{dictError && <Alert kind="warn">Справочник компаний и отделов недоступен: {dictError}</Alert>}
|
||||
{loadError && <Alert kind="err">{loadError}</Alert>}
|
||||
{deleteError && <Alert kind="err">{deleteError}</Alert>}
|
||||
{portalDictionaries.error && <Alert kind="warn">{texts.routes.dictionaryFailed(portalDictionaries.error)}</Alert>}
|
||||
{routes.error && <Alert kind="err">{routes.error}</Alert>}
|
||||
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
|
||||
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
key: 'requestType',
|
||||
header: 'Тип заявки',
|
||||
header: texts.routes.columnRequestType,
|
||||
render: r => <PrimaryCell>{requestTypeName(r.requestTypeId)}</PrimaryCell>
|
||||
},
|
||||
{
|
||||
key: 'routeType',
|
||||
header: 'Тип маршрута',
|
||||
header: texts.routes.columnRouteType,
|
||||
render: r => <Badge variant={r.routeType === RouteType.Parallel ? 'blue' : 'gold'}>{routeTypeLabels[r.routeType]}</Badge>
|
||||
},
|
||||
{ key: 'company', header: 'Компания', render: r => companyName(r.companyId) },
|
||||
{ key: 'department', header: 'Отдел', render: r => (r.departmentId ? departmentName(r.departmentId) : '—') },
|
||||
{ key: 'company', header: texts.routes.columnCompany, render: r => companyName(r.companyId) },
|
||||
{ key: 'department', header: texts.routes.columnDepartment, render: r => (r.departmentId ? departmentName(r.departmentId) : texts.common.empty) },
|
||||
{
|
||||
key: 'byProject',
|
||||
header: 'По проекту',
|
||||
header: texts.routes.columnByProject,
|
||||
align: 'center',
|
||||
render: r => (r.byProject ? <Badge variant="ok">Да</Badge> : <Badge variant="neutral">Нет</Badge>)
|
||||
render: r => (r.byProject ? <Badge variant="ok">{texts.common.yes}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>)
|
||||
},
|
||||
{
|
||||
key: 'control',
|
||||
header: 'Контроль',
|
||||
header: texts.routes.columnControl,
|
||||
align: 'center',
|
||||
render: r => (r.requiresControl ? <Badge variant="gold">Требуется</Badge> : <Badge variant="neutral">Нет</Badge>)
|
||||
render: r => (r.requiresControl ? <Badge variant="gold">{texts.routes.controlRequired}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>)
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
@@ -253,117 +119,42 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, on
|
||||
sticky: true,
|
||||
render: r => (
|
||||
<ActionsCell>
|
||||
<Button size="sm" variant="secondary" icon="clipboard" onClick={() => onOpenSteps(r)}>Шаги</Button>
|
||||
<IconButton icon="edit" label="Редактировать" onClick={() => openEdit(r)} />
|
||||
<IconButton icon="trash" label="Удалить" danger onClick={() => { setDeleteError(undefined); setDeleting(r); }} />
|
||||
<Button size="sm" variant="secondary" icon="clipboard" onClick={() => onOpenSteps(r)}>{texts.routes.openSteps}</Button>
|
||||
<IconButton icon="edit" label={texts.common.edit} onClick={() => openEdit(r)} />
|
||||
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
|
||||
</ActionsCell>
|
||||
)
|
||||
}
|
||||
]}
|
||||
rows={items}
|
||||
rows={routes.items}
|
||||
keyOf={r => r.id}
|
||||
emptyMessage={loading ? 'Загрузка...' : 'Маршрутов пока нет.'}
|
||||
emptyMessage={routes.isLoading ? texts.common.loading : texts.routes.emptyList}
|
||||
/>
|
||||
|
||||
{showForm && (
|
||||
<Modal
|
||||
title={editing ? `Маршрут #${editing.id}` : 'Новый маршрут'}
|
||||
<RouteFormModal
|
||||
api={api}
|
||||
editing={editing}
|
||||
initialRequestTypeId={filterRequestTypeId}
|
||||
requestTypes={requestTypes.items}
|
||||
companies={portalDictionaries.companies}
|
||||
departments={portalDictionaries.departments}
|
||||
dictionaryLoading={portalDictionaries.isLoading}
|
||||
onSaved={routes.reload}
|
||||
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 full>
|
||||
<Select
|
||||
options={requestTypes.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||
placeholder="Выберите тип заявки"
|
||||
value={form.requestTypeId}
|
||||
disabled={!!editing}
|
||||
onChange={e => setForm({ ...form, requestTypeId: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Тип маршрута" required full>
|
||||
<Select
|
||||
options={ROUTE_TYPE_OPTIONS}
|
||||
value={form.routeType}
|
||||
onChange={e => setForm({ ...form, routeType: e.target.value as RouteType })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Компания" required full hint="Справочник 1С (api.tnsu.kz).">
|
||||
<SearchableSelect
|
||||
options={companies.map(c => ({ value: c.id, label: c.name }))}
|
||||
value={form.companyId}
|
||||
loading={dictLoading}
|
||||
placeholder="Выберите компанию"
|
||||
searchPlaceholder="Поиск по названию компании..."
|
||||
onChange={companyId =>
|
||||
// Смена компании обнуляет отдел: прежний принадлежал другой
|
||||
// компании, и бэкенд не нашёл бы такой маршрут при resolve.
|
||||
setForm({ ...form, companyId, departmentId: '' })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Отдел"
|
||||
full
|
||||
hint={
|
||||
form.companyId
|
||||
? 'Необязательно — пусто означает маршрут «по умолчанию» для всей компании.'
|
||||
: 'Сначала выберите компанию.'
|
||||
}
|
||||
>
|
||||
<SearchableSelect
|
||||
options={departmentsForSelectedCompany.map(d => ({ value: d.id, label: d.name }))}
|
||||
value={form.departmentId}
|
||||
loading={dictLoading}
|
||||
disabled={!form.companyId}
|
||||
allowClear
|
||||
clearLabel="Без отдела (маршрут по умолчанию)"
|
||||
placeholder="Без отдела (маршрут по умолчанию)"
|
||||
searchPlaceholder="Поиск по названию отдела..."
|
||||
emptyMessage="У выбранной компании нет отделов в справочнике"
|
||||
onChange={departmentId => setForm({ ...form, departmentId })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="По проекту">
|
||||
<Checkbox
|
||||
label="Маршрут учитывает конкретный проект"
|
||||
checked={form.byProject}
|
||||
onChange={v => setForm({ ...form, byProject: v })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Контроль">
|
||||
<Checkbox
|
||||
label="Требуется отдельный контроль исполнения"
|
||||
checked={form.requiresControl}
|
||||
onChange={v => setForm({ ...form, requiresControl: v })}
|
||||
/>
|
||||
</Field>
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleting && (
|
||||
{deleteTarget.target && (
|
||||
<ConfirmDialog
|
||||
title="Удалить маршрут?"
|
||||
text={`Маршрут #${deleting.id} и все его шаги будут удалены безвозвратно.`}
|
||||
confirmLabel="Удалить"
|
||||
busy={deleteBusy}
|
||||
onCancel={() => setDeleting(undefined)}
|
||||
onConfirm={confirmDelete}
|
||||
title={texts.routes.deleteTitle}
|
||||
text={texts.routes.deleteText(deleteTarget.target.id)}
|
||||
confirmLabel={texts.common.remove}
|
||||
busy={deleteTarget.isBusy}
|
||||
onCancel={deleteTarget.cancel}
|
||||
onConfirm={deleteTarget.confirm}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function shortGuid(guid: string): string {
|
||||
return guid.length > 8 ? `${guid.slice(0, 8)}…` : guid;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user