Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/5
This commit was merged in pull request #5.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.sectionsLabel {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.6px;
|
||||
color: $gray-400;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sectionsDivider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, $gold-400, transparent 70%);
|
||||
border: 0;
|
||||
margin: 6px 0 22px;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as React from 'react';
|
||||
import styles from './DashboardScreen.module.scss';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { ScreenKey } from '../app/types';
|
||||
import { texts } from '../../texts';
|
||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
|
||||
import { AsyncList } from '../../hooks/useAsyncList';
|
||||
import { CardGrid, NavCard, StatCard, StatRow } from '../ui';
|
||||
|
||||
export interface IDashboardScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
onNavigate: (key: ScreenKey) => void;
|
||||
}
|
||||
|
||||
// Пока список грузится или запрос упал — показываем прочерк, а не «0»,
|
||||
// чтобы не соврать пользователю про реальное количество записей.
|
||||
function statValue(list: AsyncList<unknown>): React.ReactNode {
|
||||
if (list.isLoading || list.error) return texts.common.empty;
|
||||
return list.items.length;
|
||||
}
|
||||
|
||||
export const DashboardScreen: React.FC<IDashboardScreenProps> = ({ api, onNavigate }) => {
|
||||
const requestTypes = useRequestTypes(api);
|
||||
const routes = useTemplateRoutes(api);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<StatRow>
|
||||
<StatCard label={texts.dashboard.requestTypesStat} value={statValue(requestTypes)} />
|
||||
<StatCard label={texts.dashboard.routesStat} value={statValue(routes)} />
|
||||
</StatRow>
|
||||
|
||||
<div className={styles.sectionsLabel}>{texts.dashboard.sections}</div>
|
||||
<hr className={styles.sectionsDivider} />
|
||||
<CardGrid>
|
||||
<NavCard
|
||||
icon="clipboard"
|
||||
title={texts.dashboard.requestTypesCard.title}
|
||||
description={texts.dashboard.requestTypesCard.description}
|
||||
onClick={() => onNavigate('requestTypes')}
|
||||
/>
|
||||
<NavCard
|
||||
icon="route"
|
||||
title={texts.dashboard.routesCard.title}
|
||||
description={texts.dashboard.routesCard.description}
|
||||
onClick={() => onNavigate('routes')}
|
||||
/>
|
||||
<NavCard
|
||||
icon="search"
|
||||
title={texts.dashboard.resolveCard.title}
|
||||
description={texts.dashboard.resolveCard.description}
|
||||
onClick={() => onNavigate('resolve')}
|
||||
/>
|
||||
</CardGrid>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import * as React from 'react';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { RequestType } from '../../models';
|
||||
import { texts } from '../../texts';
|
||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||
import {
|
||||
ActionsCell,
|
||||
Alert,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
DataTable,
|
||||
Field,
|
||||
FormGrid,
|
||||
FormModal,
|
||||
IconButton,
|
||||
PageHeader,
|
||||
PrimaryCell,
|
||||
TextInput
|
||||
} from '../ui';
|
||||
|
||||
export interface IRequestTypesScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
}
|
||||
|
||||
interface IFormState {
|
||||
name: string;
|
||||
pageUrl: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: IFormState = { name: '', pageUrl: '' };
|
||||
|
||||
export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api }) => {
|
||||
const list = useRequestTypes(api);
|
||||
|
||||
const [editing, setEditing] = React.useState<RequestType | undefined>();
|
||||
const [showForm, setShowForm] = React.useState(false);
|
||||
const [form, setForm] = React.useState<IFormState>(EMPTY_FORM);
|
||||
const submit = useAsyncSubmit();
|
||||
|
||||
const deleteTarget = useDeleteTarget<RequestType>(item => api.requestTypes.remove(item.id), list.reload);
|
||||
|
||||
const openCreate = (): void => {
|
||||
setEditing(undefined);
|
||||
setForm(EMPTY_FORM);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const openEdit = (item: RequestType): void => {
|
||||
setEditing(item);
|
||||
setForm({ name: item.name, pageUrl: item.pageUrl });
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const submitForm = (): void => {
|
||||
if (!form.name.trim() || !form.pageUrl.trim()) {
|
||||
submit.fail(texts.requestTypes.validationRequired);
|
||||
return;
|
||||
}
|
||||
const input = { name: form.name.trim(), pageUrl: form.pageUrl.trim() };
|
||||
const request = editing ? api.requestTypes.update(editing.id, input) : api.requestTypes.create(input);
|
||||
|
||||
submit.run(() =>
|
||||
request.then(() => {
|
||||
setShowForm(false);
|
||||
list.reload();
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.requestTypes.breadcrumb }]} />
|
||||
<PageHeader
|
||||
icon="clipboard"
|
||||
subtitle={texts.requestTypes.subtitle}
|
||||
title={texts.requestTypes.title}
|
||||
actions={<Button variant="accent" icon="plus" onClick={openCreate}>{texts.requestTypes.add}</Button>}
|
||||
/>
|
||||
|
||||
{list.error && <Alert kind="err">{list.error}</Alert>}
|
||||
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
|
||||
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'name', header: texts.requestTypes.columnName, render: r => <PrimaryCell>{r.name}</PrimaryCell> },
|
||||
{ key: 'url', header: texts.requestTypes.columnPageUrl, render: r => r.pageUrl },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
width: '100px',
|
||||
align: 'right',
|
||||
sticky: true,
|
||||
render: r => (
|
||||
<ActionsCell>
|
||||
<IconButton icon="edit" label={texts.common.edit} onClick={() => openEdit(r)} />
|
||||
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
|
||||
</ActionsCell>
|
||||
)
|
||||
}
|
||||
]}
|
||||
rows={list.items}
|
||||
keyOf={r => r.id}
|
||||
emptyMessage={list.isLoading ? texts.common.loading : texts.requestTypes.emptyList}
|
||||
/>
|
||||
|
||||
{showForm && (
|
||||
<FormModal
|
||||
title={editing ? texts.requestTypes.editTitle : texts.requestTypes.createTitle}
|
||||
error={submit.error}
|
||||
isSaving={submit.isBusy}
|
||||
onSubmit={submitForm}
|
||||
onClose={() => setShowForm(false)}
|
||||
>
|
||||
<FormGrid>
|
||||
<Field label={texts.requestTypes.nameLabel} required full>
|
||||
<TextInput
|
||||
value={form.name}
|
||||
onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
placeholder={texts.requestTypes.namePlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.requestTypes.pageUrlLabel} required full hint={texts.requestTypes.pageUrlHint}>
|
||||
<TextInput
|
||||
value={form.pageUrl}
|
||||
onChange={e => setForm({ ...form, pageUrl: e.target.value })}
|
||||
placeholder={texts.requestTypes.pageUrlPlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
</FormGrid>
|
||||
</FormModal>
|
||||
)}
|
||||
|
||||
{deleteTarget.target && (
|
||||
<ConfirmDialog
|
||||
title={texts.requestTypes.deleteTitle}
|
||||
text={texts.requestTypes.deleteText(deleteTarget.target.name)}
|
||||
confirmLabel={texts.common.remove}
|
||||
busy={deleteTarget.isBusy}
|
||||
onCancel={deleteTarget.cancel}
|
||||
onConfirm={deleteTarget.confirm}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
.runButtonRow {
|
||||
margin-top: 18px;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import * as React from 'react';
|
||||
import styles from './ResolveScreen.module.scss';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||
import { Project, RouteStepStatus } from '../../models';
|
||||
import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts';
|
||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { useResolveRoute } from '../../hooks/useResolveRoute';
|
||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
||||
import {
|
||||
Alert,
|
||||
AsyncSearchableSelect,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
CellNote,
|
||||
DataTable,
|
||||
Field,
|
||||
FormGrid,
|
||||
PageHeader,
|
||||
Panel,
|
||||
PrimaryCell,
|
||||
SearchableSelect,
|
||||
Select
|
||||
} from '../ui';
|
||||
|
||||
export interface IResolveScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||
dictionary?: PortalDictionaryClient;
|
||||
currentUserEmail?: string;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
|
||||
.map(v => ({ value: v, label: routeStepStatusLabels[v] }));
|
||||
|
||||
export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary, currentUserEmail }) => {
|
||||
const requestTypes = useRequestTypes(api);
|
||||
const [requestTypeId, setRequestTypeId] = React.useState('');
|
||||
const [userEmail, setUserEmail] = React.useState('');
|
||||
const [projectId, setProjectId] = React.useState('');
|
||||
const [projectLabel, setProjectLabel] = React.useState('');
|
||||
const [status, setStatus] = React.useState<RouteStepStatus>(RouteStepStatus.Approval);
|
||||
|
||||
const resolved = useResolveRoute(api);
|
||||
const employees = usePortalEmployees(dictionary);
|
||||
|
||||
const searchProjects = React.useCallback(
|
||||
(query: string, page: number) => {
|
||||
if (!dictionary) {
|
||||
return Promise.reject(new Error(texts.resolve.projectDictionaryLoading));
|
||||
}
|
||||
return dictionary.searchProjects(query, page);
|
||||
},
|
||||
[dictionary]
|
||||
);
|
||||
|
||||
const run = (): void => {
|
||||
if (!requestTypeId) {
|
||||
resolved.fail(texts.resolve.validationRequestType);
|
||||
return;
|
||||
}
|
||||
resolved.resolve({
|
||||
requestTypeId: Number(requestTypeId),
|
||||
userEmail: userEmail.trim() || undefined,
|
||||
projectId: projectId.trim() || undefined,
|
||||
status
|
||||
});
|
||||
};
|
||||
|
||||
const result = resolved.result;
|
||||
const unresolvedCount = result ? result.steps.filter(s => !s.executorEmail).length : 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.resolve.breadcrumb }]} />
|
||||
<PageHeader icon="search" subtitle={texts.resolve.subtitle} title={texts.resolve.title} />
|
||||
|
||||
<Panel>
|
||||
<FormGrid>
|
||||
<Field label={texts.resolve.requestTypeLabel} required full>
|
||||
<Select
|
||||
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||
placeholder={texts.resolve.requestTypePlaceholder}
|
||||
value={requestTypeId}
|
||||
onChange={e => setRequestTypeId(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={texts.resolve.userEmailLabel}
|
||||
hint={currentUserEmail ? texts.resolve.userEmailHint(currentUserEmail) : texts.resolve.userEmailHintUnknown}
|
||||
>
|
||||
<SearchableSelect
|
||||
options={employees.items.map(toEmployeeOption)}
|
||||
value={userEmail}
|
||||
loading={employees.isLoading}
|
||||
allowClear
|
||||
clearLabel={texts.resolve.userEmailClear}
|
||||
placeholder={texts.resolve.userEmailPlaceholder}
|
||||
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||
emptyMessage={texts.select.employeesEmpty}
|
||||
onChange={setUserEmail}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.resolve.projectLabel} hint={texts.resolve.projectHint}>
|
||||
<AsyncSearchableSelect<Project>
|
||||
value={projectId}
|
||||
selectedLabel={projectLabel}
|
||||
disabled={!dictionary}
|
||||
allowClear
|
||||
clearLabel={texts.resolve.projectClear}
|
||||
placeholder={dictionary ? texts.resolve.projectPlaceholder : texts.resolve.projectLoadingPlaceholder}
|
||||
searchPlaceholder={texts.resolve.projectSearchPlaceholder}
|
||||
search={searchProjects}
|
||||
getValue={p => p.oid}
|
||||
getLabel={p => p.name}
|
||||
getSublabel={p => [p.code, p.status].filter(Boolean).join(' · ') || undefined}
|
||||
onChange={(nextId, item) => {
|
||||
setProjectId(nextId);
|
||||
setProjectLabel(item ? item.name : '');
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.resolve.statusLabel} required>
|
||||
<Select options={STATUS_OPTIONS} value={status} onChange={e => setStatus(e.target.value as RouteStepStatus)} />
|
||||
</Field>
|
||||
</FormGrid>
|
||||
<div className={styles.runButtonRow}>
|
||||
<Button variant="accent" icon="search" onClick={run} disabled={resolved.isBusy}>
|
||||
{resolved.isBusy ? texts.resolve.running : texts.resolve.run}
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{resolved.error && <Alert kind="err">{resolved.error}</Alert>}
|
||||
|
||||
{result && (
|
||||
<>
|
||||
<Alert kind="ok">
|
||||
{texts.resolve.summary(result.routeId, routeTypeLabels[result.routeType], result.requiresControl)}
|
||||
</Alert>
|
||||
{unresolvedCount > 0 && <Alert kind="warn">{texts.resolve.unresolved(unresolvedCount)}</Alert>}
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'order', header: texts.resolve.columnOrderNo, width: '50px', render: r => r.orderNo },
|
||||
{
|
||||
key: 'executor',
|
||||
header: texts.resolve.columnExecutor,
|
||||
render: r => {
|
||||
const employee = findEmployeeByEmail(employees.items, r.executorEmail);
|
||||
return (
|
||||
<div>
|
||||
<PrimaryCell>{employee ? employee.fio : (r.executorEmail ?? texts.common.empty)}</PrimaryCell>
|
||||
{employee && <CellNote>{employee.email}</CellNote>}
|
||||
{r.warning && <CellNote kind="warn">{r.warning}</CellNote>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'position',
|
||||
header: texts.resolve.columnPosition,
|
||||
render: r => (
|
||||
<div>
|
||||
{r.position ?? texts.common.empty}
|
||||
{!r.position && !r.warning && r.executorEmail && <CellNote>{texts.resolve.positionMissing}</CellNote>}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'reassign',
|
||||
header: texts.resolve.columnReassign,
|
||||
align: 'center',
|
||||
render: r => (r.allowReassignToOtherApprovers ? texts.resolve.reassignAllowed : texts.common.no)
|
||||
}
|
||||
]}
|
||||
rows={result.steps}
|
||||
keyOf={r => r.id}
|
||||
emptyMessage={texts.resolve.emptySteps}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
import * as React from 'react';
|
||||
import { Company, Department, RequestType, RouteType, TemplateRoute } from '../../models';
|
||||
import { routeTypeLabels, texts } from '../../texts';
|
||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { Checkbox, Field, FormGrid, FormModal, SearchableSelect, Select } from '../ui';
|
||||
|
||||
export interface IRouteFormModalProps {
|
||||
api: ApprovalRouteApi;
|
||||
/** Undefined — создание нового маршрута, иначе редактирование существующего. */
|
||||
editing?: TemplateRoute;
|
||||
/** Тип заявки, под которым открыли форму создания (например, из активного фильтра списка). */
|
||||
initialRequestTypeId: string;
|
||||
requestTypes: RequestType[];
|
||||
companies: Company[];
|
||||
departments: Department[];
|
||||
dictionaryLoading: boolean;
|
||||
onSaved: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface IFormState {
|
||||
requestTypeId: string;
|
||||
routeType: RouteType;
|
||||
companyId: string;
|
||||
departmentId: string;
|
||||
byProject: boolean;
|
||||
requiresControl: boolean;
|
||||
}
|
||||
|
||||
const ROUTE_TYPE_OPTIONS = Object.values(RouteType)
|
||||
.filter(v => v !== RouteType.None)
|
||||
.map(v => ({ value: v, label: routeTypeLabels[v] }));
|
||||
|
||||
function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: string): IFormState {
|
||||
if (!editing) {
|
||||
return {
|
||||
requestTypeId: initialRequestTypeId,
|
||||
routeType: RouteType.Sequential,
|
||||
companyId: '',
|
||||
departmentId: '',
|
||||
byProject: false,
|
||||
requiresControl: false
|
||||
};
|
||||
}
|
||||
return {
|
||||
requestTypeId: String(editing.requestTypeId),
|
||||
routeType: editing.routeType,
|
||||
companyId: editing.companyId,
|
||||
departmentId: editing.departmentId ?? '',
|
||||
byProject: editing.byProject,
|
||||
requiresControl: editing.requiresControl
|
||||
};
|
||||
}
|
||||
|
||||
/** Форма создания/редактирования маршрута — вынесена из RoutesScreen, чтобы тот не разрастался. */
|
||||
export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
|
||||
api,
|
||||
editing,
|
||||
initialRequestTypeId,
|
||||
requestTypes,
|
||||
companies,
|
||||
departments,
|
||||
dictionaryLoading,
|
||||
onSaved,
|
||||
onClose
|
||||
}) => {
|
||||
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, initialRequestTypeId));
|
||||
const submit = useAsyncSubmit();
|
||||
|
||||
// Отдел принадлежит компании, поэтому список сужаем под выбранную:
|
||||
// иначе пользователь листает ~700 позиций из всех юрлиц сразу.
|
||||
const departmentsForSelectedCompany = React.useMemo(
|
||||
() => (form.companyId ? departments.filter(d => d.companyId === form.companyId) : []),
|
||||
[departments, form.companyId]
|
||||
);
|
||||
|
||||
const submitForm = (): void => {
|
||||
if (!editing && !form.requestTypeId) {
|
||||
submit.fail(texts.routes.validationRequestType);
|
||||
return;
|
||||
}
|
||||
if (!form.companyId.trim()) {
|
||||
submit.fail(texts.routes.validationCompany);
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
submit.run(() =>
|
||||
request.then(() => {
|
||||
onSaved();
|
||||
onClose();
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
title={editing ? texts.routes.editTitle(editing.id) : texts.routes.createTitle}
|
||||
error={submit.error}
|
||||
isSaving={submit.isBusy}
|
||||
onSubmit={submitForm}
|
||||
onClose={onClose}
|
||||
>
|
||||
<FormGrid>
|
||||
<Field label={texts.routes.requestTypeLabel} required full>
|
||||
<Select
|
||||
options={requestTypes.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||
placeholder={texts.routes.requestTypePlaceholder}
|
||||
value={form.requestTypeId}
|
||||
disabled={!!editing}
|
||||
onChange={e => setForm({ ...form, requestTypeId: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.routes.routeTypeLabel} required full>
|
||||
<Select
|
||||
options={ROUTE_TYPE_OPTIONS}
|
||||
value={form.routeType}
|
||||
onChange={e => setForm({ ...form, routeType: e.target.value as RouteType })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.routes.companyLabel} required full hint={texts.routes.companyHint}>
|
||||
<SearchableSelect
|
||||
options={companies.map(c => ({ value: c.id, label: c.name }))}
|
||||
value={form.companyId}
|
||||
loading={dictionaryLoading}
|
||||
placeholder={texts.routes.companyPlaceholder}
|
||||
searchPlaceholder={texts.routes.companySearchPlaceholder}
|
||||
onChange={companyId =>
|
||||
// Смена компании обнуляет отдел: прежний принадлежал другой
|
||||
// компании, и бэкенд не нашёл бы такой маршрут при resolve.
|
||||
setForm({ ...form, companyId, departmentId: '' })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={texts.routes.departmentLabel}
|
||||
full
|
||||
hint={form.companyId ? texts.routes.departmentHint : texts.routes.departmentHintNoCompany}
|
||||
>
|
||||
<SearchableSelect
|
||||
options={departmentsForSelectedCompany.map(d => ({ value: d.id, label: d.name }))}
|
||||
value={form.departmentId}
|
||||
loading={dictionaryLoading}
|
||||
disabled={!form.companyId}
|
||||
allowClear
|
||||
clearLabel={texts.routes.departmentPlaceholder}
|
||||
placeholder={texts.routes.departmentPlaceholder}
|
||||
searchPlaceholder={texts.routes.departmentSearchPlaceholder}
|
||||
emptyMessage={texts.routes.departmentEmpty}
|
||||
onChange={departmentId => setForm({ ...form, departmentId })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.routes.byProjectLabel}>
|
||||
<Checkbox
|
||||
label={texts.routes.byProjectCheckbox}
|
||||
checked={form.byProject}
|
||||
onChange={v => setForm({ ...form, byProject: v })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.routes.controlLabel}>
|
||||
<Checkbox
|
||||
label={texts.routes.controlCheckbox}
|
||||
checked={form.requiresControl}
|
||||
onChange={v => setForm({ ...form, requiresControl: v })}
|
||||
/>
|
||||
</Field>
|
||||
</FormGrid>
|
||||
</FormModal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,223 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Employee,
|
||||
HeadType,
|
||||
ProjectRole,
|
||||
RouteStepStatus,
|
||||
StepExecutorKind,
|
||||
TemplateRoute,
|
||||
TemplateRouteStep
|
||||
} from '../../models';
|
||||
import { headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts';
|
||||
import { detectExecutorKind } from '../../utils/stepExecutor';
|
||||
import { toEmployeeOption } from '../../utils/employee';
|
||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { Checkbox, Field, FormGrid, FormModal, RadioGroup, SearchableSelect, Select, TextInput } from '../ui';
|
||||
|
||||
export interface IRouteStepFormModalProps {
|
||||
api: ApprovalRouteApi;
|
||||
route: TemplateRoute;
|
||||
/** Undefined — создание нового шага, иначе редактирование существующего. */
|
||||
editing?: TemplateRouteStep;
|
||||
nextOrderNo: number;
|
||||
employees: Employee[];
|
||||
employeesLoading: boolean;
|
||||
onSaved: (savedStep: TemplateRouteStep) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface IFormState {
|
||||
orderNo: string;
|
||||
status: RouteStepStatus;
|
||||
kind: StepExecutorKind;
|
||||
executorEmail: string;
|
||||
position: string;
|
||||
projectRole: ProjectRole;
|
||||
headType: HeadType;
|
||||
allowReassignToOtherApprovers: boolean;
|
||||
}
|
||||
|
||||
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] }));
|
||||
|
||||
function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState {
|
||||
if (!editing) {
|
||||
return {
|
||||
orderNo: String(nextOrderNo),
|
||||
status: RouteStepStatus.Approval,
|
||||
kind: 'email',
|
||||
executorEmail: '',
|
||||
position: '',
|
||||
projectRole: ProjectRole.ProjectManager,
|
||||
headType: HeadType.Chief,
|
||||
allowReassignToOtherApprovers: false
|
||||
};
|
||||
}
|
||||
return {
|
||||
orderNo: String(editing.orderNo),
|
||||
status: editing.status,
|
||||
kind: detectExecutorKind(editing),
|
||||
executorEmail: editing.executorEmail ?? '',
|
||||
position: editing.position ?? '',
|
||||
projectRole: editing.projectRole && editing.projectRole !== ProjectRole.None ? editing.projectRole : ProjectRole.ProjectManager,
|
||||
headType: editing.headType && editing.headType !== HeadType.None ? editing.headType : HeadType.Chief,
|
||||
allowReassignToOtherApprovers: editing.allowReassignToOtherApprovers
|
||||
};
|
||||
}
|
||||
|
||||
/** Форма создания/редактирования шага маршрута — вынесена из RouteStepsScreen. */
|
||||
export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
api,
|
||||
route,
|
||||
editing,
|
||||
nextOrderNo,
|
||||
employees,
|
||||
employeesLoading,
|
||||
onSaved,
|
||||
onClose
|
||||
}) => {
|
||||
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo));
|
||||
const submit = useAsyncSubmit();
|
||||
|
||||
const submitForm = (): void => {
|
||||
const orderNo = Number(form.orderNo);
|
||||
if (!orderNo || orderNo < 1) {
|
||||
submit.fail(texts.routeSteps.validationOrderNo);
|
||||
return;
|
||||
}
|
||||
if (form.kind === 'email' && !form.executorEmail.trim()) {
|
||||
submit.fail(texts.routeSteps.validationExecutor);
|
||||
return;
|
||||
}
|
||||
if (form.kind === 'position' && !form.position.trim()) {
|
||||
submit.fail(texts.routeSteps.validationPosition);
|
||||
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
|
||||
};
|
||||
|
||||
const request = editing
|
||||
? api.templateRouteSteps.update(route.id, editing.id, payload)
|
||||
: api.templateRouteSteps.create(route.id, payload);
|
||||
|
||||
submit.run(() =>
|
||||
request.then(step => {
|
||||
onSaved(step);
|
||||
onClose();
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
title={editing ? texts.routeSteps.editTitle(editing.orderNo) : texts.routeSteps.createTitle}
|
||||
error={submit.error}
|
||||
isSaving={submit.isBusy}
|
||||
onSubmit={submitForm}
|
||||
onClose={onClose}
|
||||
>
|
||||
<FormGrid>
|
||||
<Field label={texts.routeSteps.orderNoLabel} required>
|
||||
<TextInput
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.orderNo}
|
||||
onChange={e => setForm({ ...form, orderNo: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.routeSteps.statusLabel} required>
|
||||
<Select
|
||||
options={STATUS_OPTIONS}
|
||||
value={form.status}
|
||||
onChange={e => setForm({ ...form, status: e.target.value as RouteStepStatus })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={texts.routeSteps.kindLabel} required full hint={texts.routeSteps.kindHint}>
|
||||
<RadioGroup
|
||||
name="executorKind"
|
||||
value={form.kind}
|
||||
onChange={kind => setForm({ ...form, kind })}
|
||||
options={[
|
||||
{ value: 'email', label: texts.routeSteps.kindEmail },
|
||||
{ value: 'position', label: texts.routeSteps.kindPosition },
|
||||
{
|
||||
value: 'projectRole',
|
||||
label: texts.routeSteps.kindProjectRole,
|
||||
disabled: !route.byProject,
|
||||
disabledHint: texts.routeSteps.kindProjectRoleDisabled
|
||||
},
|
||||
{ value: 'headType', label: texts.routeSteps.kindHeadType }
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{form.kind === 'email' && (
|
||||
<Field label={texts.routeSteps.executorLabel} required full>
|
||||
<SearchableSelect
|
||||
options={employees.map(toEmployeeOption)}
|
||||
value={form.executorEmail}
|
||||
loading={employeesLoading}
|
||||
placeholder={texts.routeSteps.executorPlaceholder}
|
||||
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||
emptyMessage={texts.select.employeesEmpty}
|
||||
onChange={executorEmail => setForm({ ...form, executorEmail })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{form.kind === 'position' && (
|
||||
<Field label={texts.routeSteps.positionLabel} required full hint={texts.routeSteps.positionHint}>
|
||||
<TextInput
|
||||
value={form.position}
|
||||
onChange={e => setForm({ ...form, position: e.target.value })}
|
||||
placeholder={texts.routeSteps.positionPlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{form.kind === 'projectRole' && (
|
||||
<Field label={texts.routeSteps.projectRoleLabel} 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={texts.routeSteps.headTypeLabel} required full>
|
||||
<Select
|
||||
options={HEAD_TYPE_OPTIONS}
|
||||
value={form.headType}
|
||||
onChange={e => setForm({ ...form, headType: e.target.value as HeadType })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label={texts.routeSteps.reassignLabel} full>
|
||||
<Checkbox
|
||||
label={texts.routeSteps.reassignCheckbox}
|
||||
checked={form.allowReassignToOtherApprovers}
|
||||
onChange={v => setForm({ ...form, allowReassignToOtherApprovers: v })}
|
||||
/>
|
||||
</Field>
|
||||
</FormGrid>
|
||||
</FormModal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
import * as React from 'react';
|
||||
import { RouteStepFormModal } from './RouteStepFormModal';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||
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 { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||
import {
|
||||
ActionsCell,
|
||||
Alert,
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
CellNote,
|
||||
ConfirmDialog,
|
||||
DataTable,
|
||||
IconButton,
|
||||
PageHeader,
|
||||
PrimaryCell
|
||||
} from '../ui';
|
||||
|
||||
export interface IRouteStepsScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
route: TemplateRoute;
|
||||
requestTypeName: string;
|
||||
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||
dictionary?: PortalDictionaryClient;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route, requestTypeName, dictionary, onBack }) => {
|
||||
const steps = useRouteSteps(api, route.id);
|
||||
const employees = usePortalEmployees(dictionary);
|
||||
const [savedWarning, setSavedWarning] = React.useState<string | undefined>();
|
||||
|
||||
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
|
||||
const [showForm, setShowForm] = React.useState(false);
|
||||
|
||||
const deleteTarget = useDeleteTarget<TemplateRouteStep>(
|
||||
item => api.templateRouteSteps.remove(route.id, item.id),
|
||||
steps.reload
|
||||
);
|
||||
|
||||
const openCreate = (): void => {
|
||||
setEditing(undefined);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const openEdit = (item: TemplateRouteStep): void => {
|
||||
setEditing(item);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleSaved = (savedStep: TemplateRouteStep): void => {
|
||||
setSavedWarning(savedStep.warning ?? undefined);
|
||||
steps.reload();
|
||||
};
|
||||
|
||||
const nextOrderNo = steps.items.length > 0 ? Math.max(...steps.items.map(s => s.orderNo)) + 1 : 1;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: texts.common.home },
|
||||
{ label: texts.routes.breadcrumb, onClick: onBack },
|
||||
{ label: texts.routeSteps.breadcrumb(route.id) }
|
||||
]}
|
||||
/>
|
||||
<PageHeader
|
||||
icon="flag-check"
|
||||
subtitle={texts.routeSteps.subtitle(requestTypeName, routeTypeLabels[route.routeType])}
|
||||
title={texts.routeSteps.title(route.id)}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="ghost" icon="arrow-left" onClick={onBack}>{texts.routeSteps.back}</Button>
|
||||
<Button variant="accent" icon="plus" onClick={openCreate}>{texts.routeSteps.add}</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{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: texts.routeSteps.columnOrderNo, width: '50px', render: r => r.orderNo },
|
||||
{
|
||||
key: 'executor',
|
||||
header: texts.routeSteps.columnExecutor,
|
||||
render: r => {
|
||||
const executor = describeStepExecutor(r, employees.items);
|
||||
return (
|
||||
<div>
|
||||
<PrimaryCell>{executor.primary}</PrimaryCell>
|
||||
{executor.secondary && <CellNote>{executor.secondary}</CellNote>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: texts.routeSteps.columnStatus,
|
||||
render: r => (
|
||||
<Badge variant={r.status === RouteStepStatus.Approval ? 'gold' : 'blue'}>
|
||||
{routeStepStatusLabels[r.status]}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'reassign',
|
||||
header: texts.routeSteps.columnReassign,
|
||||
align: 'center',
|
||||
render: r => (r.allowReassignToOtherApprovers ? <Badge variant="ok">{texts.routeSteps.reassignAllowed}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>)
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
width: '100px',
|
||||
align: 'right',
|
||||
sticky: true,
|
||||
render: r => (
|
||||
<ActionsCell>
|
||||
<IconButton icon="edit" label={texts.common.edit} onClick={() => openEdit(r)} />
|
||||
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
|
||||
</ActionsCell>
|
||||
)
|
||||
}
|
||||
]}
|
||||
rows={steps.items}
|
||||
keyOf={r => r.id}
|
||||
emptyMessage={steps.isLoading ? texts.common.loading : texts.routeSteps.emptyList}
|
||||
/>
|
||||
|
||||
{showForm && (
|
||||
<RouteStepFormModal
|
||||
api={api}
|
||||
route={route}
|
||||
editing={editing}
|
||||
nextOrderNo={nextOrderNo}
|
||||
employees={employees.items}
|
||||
employeesLoading={employees.isLoading}
|
||||
onSaved={handleSaved}
|
||||
onClose={() => setShowForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteTarget.target && (
|
||||
<ConfirmDialog
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
.filter {
|
||||
margin-bottom: 18px;
|
||||
max-width: 320px;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import * as React from 'react';
|
||||
import styles from './RoutesScreen.module.scss';
|
||||
import { RouteFormModal } from './RouteFormModal';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||
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,
|
||||
ConfirmDialog,
|
||||
DataTable,
|
||||
Field,
|
||||
IconButton,
|
||||
PageHeader,
|
||||
PrimaryCell,
|
||||
Select
|
||||
} from '../ui';
|
||||
|
||||
export interface IRoutesScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
dictionary?: PortalDictionaryClient;
|
||||
onOpenSteps: (route: TemplateRoute) => void;
|
||||
}
|
||||
|
||||
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, onOpenSteps }) => {
|
||||
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 deleteTarget = useDeleteTarget<TemplateRoute>(item => api.templateRoutes.remove(item.id), routes.reload);
|
||||
|
||||
const requestTypeName = (id: number): string => requestTypes.items.find(rt => rt.id === id)?.name ?? `#${id}`;
|
||||
|
||||
// Пока справочник не загрузился (или маршрут ссылается на удалённую запись),
|
||||
// показываем усечённый Guid — чтобы строка таблицы не выглядела пустой.
|
||||
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);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const openEdit = (item: TemplateRoute): void => {
|
||||
setEditing(item);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.routes.breadcrumb }]} />
|
||||
<PageHeader
|
||||
icon="route"
|
||||
subtitle={texts.routes.subtitle}
|
||||
title={texts.routes.title}
|
||||
actions={<Button variant="accent" icon="plus" onClick={openCreate}>{texts.routes.add}</Button>}
|
||||
/>
|
||||
|
||||
<div className={styles.filter}>
|
||||
<Field label={texts.routes.filterLabel}>
|
||||
<Select
|
||||
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>
|
||||
|
||||
{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: texts.routes.columnRequestType,
|
||||
render: r => <PrimaryCell>{requestTypeName(r.requestTypeId)}</PrimaryCell>
|
||||
},
|
||||
{
|
||||
key: 'routeType',
|
||||
header: texts.routes.columnRouteType,
|
||||
render: r => <Badge variant={r.routeType === RouteType.Parallel ? 'blue' : 'gold'}>{routeTypeLabels[r.routeType]}</Badge>
|
||||
},
|
||||
{ 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: texts.routes.columnByProject,
|
||||
align: 'center',
|
||||
render: r => (r.byProject ? <Badge variant="ok">{texts.common.yes}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>)
|
||||
},
|
||||
{
|
||||
key: 'control',
|
||||
header: texts.routes.columnControl,
|
||||
align: 'center',
|
||||
render: r => (r.requiresControl ? <Badge variant="gold">{texts.routes.controlRequired}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>)
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
width: '160px',
|
||||
align: 'right',
|
||||
sticky: true,
|
||||
render: r => (
|
||||
<ActionsCell>
|
||||
<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={routes.items}
|
||||
keyOf={r => r.id}
|
||||
emptyMessage={routes.isLoading ? texts.common.loading : texts.routes.emptyList}
|
||||
/>
|
||||
|
||||
{showForm && (
|
||||
<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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteTarget.target && (
|
||||
<ConfirmDialog
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user