370 lines
14 KiB
TypeScript
370 lines
14 KiB
TypeScript
import * as React from 'react';
|
||
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 {
|
||
ActionsCell,
|
||
Alert,
|
||
Badge,
|
||
Breadcrumbs,
|
||
Button,
|
||
Checkbox,
|
||
ConfirmDialog,
|
||
DataTable,
|
||
Field,
|
||
FormGrid,
|
||
IconButton,
|
||
Modal,
|
||
PageHeader,
|
||
PrimaryCell,
|
||
SearchableSelect,
|
||
Select
|
||
} from '../ui';
|
||
|
||
export interface IRoutesScreenProps {
|
||
api: ApprovalRouteApi;
|
||
dictionary?: PortalDictionaryClient;
|
||
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 [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);
|
||
|
||
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}`;
|
||
|
||
// Пока справочник не загрузился (или маршрут ссылается на удалённую запись),
|
||
// показываем усечённый 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 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: 'Маршруты согласования' }]} />
|
||
<PageHeader
|
||
icon="route"
|
||
subtitle="Настройка согласований"
|
||
title="Маршруты согласования"
|
||
actions={<Button variant="accent" icon="plus" onClick={openCreate}>Добавить маршрут</Button>}
|
||
/>
|
||
|
||
<div style={{ marginBottom: 18, maxWidth: 320 }}>
|
||
<Field label="Фильтр по типу заявки">
|
||
<Select
|
||
options={requestTypes.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||
placeholder="Все типы заявок"
|
||
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>}
|
||
|
||
<DataTable
|
||
columns={[
|
||
{
|
||
key: 'requestType',
|
||
header: 'Тип заявки',
|
||
render: r => <PrimaryCell>{requestTypeName(r.requestTypeId)}</PrimaryCell>
|
||
},
|
||
{
|
||
key: 'routeType',
|
||
header: 'Тип маршрута',
|
||
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: 'byProject',
|
||
header: 'По проекту',
|
||
align: 'center',
|
||
render: r => (r.byProject ? <Badge variant="ok">Да</Badge> : <Badge variant="neutral">Нет</Badge>)
|
||
},
|
||
{
|
||
key: 'control',
|
||
header: 'Контроль',
|
||
align: 'center',
|
||
render: r => (r.requiresControl ? <Badge variant="gold">Требуется</Badge> : <Badge variant="neutral">Нет</Badge>)
|
||
},
|
||
{
|
||
key: 'actions',
|
||
header: '',
|
||
width: '160px',
|
||
align: 'right',
|
||
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); }} />
|
||
</ActionsCell>
|
||
)
|
||
}
|
||
]}
|
||
rows={items}
|
||
keyOf={r => r.id}
|
||
emptyMessage={loading ? 'Загрузка...' : 'Маршрутов пока нет.'}
|
||
/>
|
||
|
||
{showForm && (
|
||
<Modal
|
||
title={editing ? `Маршрут #${editing.id}` : 'Новый маршрут'}
|
||
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 && (
|
||
<ConfirmDialog
|
||
title="Удалить маршрут?"
|
||
text={`Маршрут #${deleting.id} и все его шаги будут удалены безвозвратно.`}
|
||
confirmLabel="Удалить"
|
||
busy={deleteBusy}
|
||
onCancel={() => setDeleting(undefined)}
|
||
onConfirm={confirmDelete}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
function shortGuid(guid: string): string {
|
||
return guid.length > 8 ? `${guid.slice(0, 8)}…` : guid;
|
||
}
|