Files
tnsu-approval-route/frontend/src/webparts/approvalAdmin/components/screens/RoutesScreen.tsx
T
2026-07-29 16:09:19 +05:00

370 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}