Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/6
This commit was merged in pull request #6.
This commit is contained in:
@@ -1,13 +1,17 @@
|
||||
import * as React from 'react';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { formatSiteLabel, SharePointListsClient, SharePointSiteSuggestion } from '../../services/sharePointLists';
|
||||
import { RequestType } from '../../models';
|
||||
import { texts } from '../../texts';
|
||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { useSharePointLists } from '../../hooks/useSharePointLists';
|
||||
import { useSharePointSiteTitle } from '../../hooks/useSharePointSiteTitle';
|
||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||
import {
|
||||
ActionsCell,
|
||||
Alert,
|
||||
AsyncSearchableSelect,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
@@ -18,27 +22,44 @@ import {
|
||||
IconButton,
|
||||
PageHeader,
|
||||
PrimaryCell,
|
||||
Select,
|
||||
TextInput
|
||||
} from '../ui';
|
||||
|
||||
export interface IRequestTypesScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
sharePoint: SharePointListsClient;
|
||||
}
|
||||
|
||||
interface IFormState {
|
||||
name: string;
|
||||
pageUrl: string;
|
||||
section: string;
|
||||
siteUrl: string;
|
||||
listName: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: IFormState = { name: '', pageUrl: '' };
|
||||
const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', listName: '' };
|
||||
|
||||
export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api }) => {
|
||||
/** Ячейка «Сайт SharePoint» — подтягивает заголовок сайта по url, хранящемуся на бэкенде. */
|
||||
const SiteUrlCell: React.FC<{ client: SharePointListsClient; siteUrl: string | null }> = ({ client, siteUrl }) => {
|
||||
const { title } = useSharePointSiteTitle(client, siteUrl);
|
||||
return <>{siteUrl ? formatSiteLabel(title, siteUrl) : texts.common.empty}</>;
|
||||
};
|
||||
|
||||
export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sharePoint }) => {
|
||||
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 sharePointLists = useSharePointLists(sharePoint, form.siteUrl);
|
||||
const siteTitle = useSharePointSiteTitle(sharePoint, form.siteUrl);
|
||||
const searchSites = React.useCallback(
|
||||
(query: string, page: number) => sharePoint.searchSites(query, page),
|
||||
[sharePoint]
|
||||
);
|
||||
|
||||
const deleteTarget = useDeleteTarget<RequestType>(item => api.requestTypes.remove(item.id), list.reload);
|
||||
|
||||
@@ -50,16 +71,35 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api })
|
||||
|
||||
const openEdit = (item: RequestType): void => {
|
||||
setEditing(item);
|
||||
setForm({ name: item.name, pageUrl: item.pageUrl });
|
||||
setForm({
|
||||
name: item.name,
|
||||
pageUrl: item.pageUrl,
|
||||
section: item.section,
|
||||
siteUrl: item.siteUrl ?? '',
|
||||
listName: item.listName ?? ''
|
||||
});
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const submitForm = (): void => {
|
||||
if (!form.name.trim() || !form.pageUrl.trim()) {
|
||||
if (!form.name.trim() || !form.pageUrl.trim() || !form.section.trim()) {
|
||||
submit.fail(texts.requestTypes.validationRequired);
|
||||
return;
|
||||
}
|
||||
const input = { name: form.name.trim(), pageUrl: form.pageUrl.trim() };
|
||||
const siteUrl = form.siteUrl.trim();
|
||||
const listName = form.listName.trim();
|
||||
if (Boolean(siteUrl) !== Boolean(listName)) {
|
||||
submit.fail(texts.requestTypes.validationListReference);
|
||||
return;
|
||||
}
|
||||
|
||||
const input = {
|
||||
name: form.name.trim(),
|
||||
pageUrl: form.pageUrl.trim(),
|
||||
section: form.section.trim(),
|
||||
siteUrl: siteUrl || null,
|
||||
listName: listName || null
|
||||
};
|
||||
const request = editing ? api.requestTypes.update(editing.id, input) : api.requestTypes.create(input);
|
||||
|
||||
submit.run(() =>
|
||||
@@ -70,6 +110,8 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api })
|
||||
);
|
||||
};
|
||||
|
||||
const listNameOptions = sharePointLists.items.map(l => ({ value: l.title, label: l.title }));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.requestTypes.breadcrumb }]} />
|
||||
@@ -87,6 +129,9 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api })
|
||||
columns={[
|
||||
{ key: 'name', header: texts.requestTypes.columnName, render: r => <PrimaryCell>{r.name}</PrimaryCell> },
|
||||
{ key: 'url', header: texts.requestTypes.columnPageUrl, render: r => r.pageUrl },
|
||||
{ key: 'section', header: texts.requestTypes.columnSection, render: r => r.section },
|
||||
{ key: 'listName', header: texts.requestTypes.columnListName, render: r => r.listName ?? texts.common.empty },
|
||||
{ key: 'siteUrl', header: texts.requestTypes.columnSiteUrl, render: r => <SiteUrlCell client={sharePoint} siteUrl={r.siteUrl} /> },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
@@ -129,6 +174,53 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api })
|
||||
placeholder={texts.requestTypes.pageUrlPlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.requestTypes.sectionLabel} required full>
|
||||
<TextInput
|
||||
value={form.section}
|
||||
onChange={e => setForm({ ...form, section: e.target.value })}
|
||||
placeholder={texts.requestTypes.sectionPlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.requestTypes.siteUrlLabel} full hint={texts.requestTypes.siteUrlHint}>
|
||||
<AsyncSearchableSelect<SharePointSiteSuggestion>
|
||||
value={form.siteUrl}
|
||||
selectedLabel={siteTitle.title ?? form.siteUrl}
|
||||
selectedSublabel={siteTitle.title ? form.siteUrl : undefined}
|
||||
allowClear
|
||||
clearLabel={texts.select.notSelected}
|
||||
placeholder={texts.requestTypes.siteUrlPlaceholder}
|
||||
searchPlaceholder={texts.requestTypes.siteUrlSearchPlaceholder}
|
||||
search={searchSites}
|
||||
getValue={s => s.url}
|
||||
getLabel={s => s.title}
|
||||
getSublabel={s => s.url}
|
||||
onChange={(siteUrl, item) => {
|
||||
if (siteUrl && item) sharePoint.cacheSiteTitle(siteUrl, item.title);
|
||||
setForm({ ...form, siteUrl, listName: '' });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={texts.requestTypes.listNameLabel}
|
||||
full
|
||||
hint={sharePointLists.error ? texts.requestTypes.listNameError(sharePointLists.error) : texts.requestTypes.listNameHint}
|
||||
>
|
||||
<Select
|
||||
options={listNameOptions}
|
||||
value={form.listName}
|
||||
disabled={!form.siteUrl.trim() || sharePointLists.isLoading}
|
||||
placeholder={
|
||||
!form.siteUrl.trim()
|
||||
? texts.requestTypes.listNamePlaceholder
|
||||
: sharePointLists.isLoading
|
||||
? texts.requestTypes.listNameLoading
|
||||
: listNameOptions.length === 0
|
||||
? texts.requestTypes.listNameEmpty
|
||||
: texts.select.placeholder
|
||||
}
|
||||
onChange={e => setForm({ ...form, listName: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</FormGrid>
|
||||
</FormModal>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,37 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.conditions {
|
||||
margin-top: 20px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
.conditionsHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.conditionsTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.conditionsHint {
|
||||
font-size: 11.5px;
|
||||
color: $gray-400;
|
||||
}
|
||||
|
||||
.conditionRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.runButtonRow {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@ 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 { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
|
||||
import { Project, ResolveFieldValue, RouteStepStatus } from '../../models';
|
||||
import { routeStepStatusLabels, texts } from '../../texts';
|
||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { useResolveRoute } from '../../hooks/useResolveRoute';
|
||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||
import { useSharePointFields } from '../../hooks/useSharePointFields';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
||||
import {
|
||||
Alert,
|
||||
@@ -17,30 +20,58 @@ import {
|
||||
DataTable,
|
||||
Field,
|
||||
FormGrid,
|
||||
IconButton,
|
||||
PageHeader,
|
||||
Panel,
|
||||
PrimaryCell,
|
||||
SearchableSelect,
|
||||
Select
|
||||
Select,
|
||||
TextInput
|
||||
} from '../ui';
|
||||
|
||||
export interface IResolveScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||
dictionary?: PortalDictionaryClient;
|
||||
sharePoint: SharePointListsClient;
|
||||
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 }) => {
|
||||
// Форма проверки маршрута заполняется одними и теми же параметрами раз за разом
|
||||
// (тип заявки, инициатор, проект, статус) — персистим её в localStorage, чтобы
|
||||
// не вводить всё заново при каждом открытии экрана.
|
||||
const RESOLVE_FORM_STORAGE_KEY = 'approvalAdmin.resolveForm';
|
||||
|
||||
interface IResolveFormState {
|
||||
requestTypeId: string;
|
||||
userEmail: string;
|
||||
projectId: string;
|
||||
projectLabel: string;
|
||||
status: RouteStepStatus;
|
||||
fieldValues: ResolveFieldValue[];
|
||||
}
|
||||
|
||||
const EMPTY_RESOLVE_FORM: IResolveFormState = {
|
||||
requestTypeId: '',
|
||||
userEmail: '',
|
||||
projectId: '',
|
||||
projectLabel: '',
|
||||
status: RouteStepStatus.Approval,
|
||||
fieldValues: []
|
||||
};
|
||||
|
||||
export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary, sharePoint, 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 [form, setForm] = useLocalStorageState<IResolveFormState>(RESOLVE_FORM_STORAGE_KEY, EMPTY_RESOLVE_FORM);
|
||||
const { requestTypeId, userEmail, projectId, projectLabel, status, fieldValues = [] } = form;
|
||||
|
||||
const requestType = requestTypes.items.find(rt => rt.id === Number(requestTypeId));
|
||||
const hasListName = Boolean(requestType?.listName);
|
||||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl, requestType?.listName);
|
||||
const fieldNameOptions = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
|
||||
|
||||
const resolved = useResolveRoute(api);
|
||||
const employees = usePortalEmployees(dictionary);
|
||||
@@ -55,16 +86,35 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
||||
[dictionary]
|
||||
);
|
||||
|
||||
const updateFieldValue = (index: number, patch: Partial<ResolveFieldValue>): void => {
|
||||
setForm({ ...form, fieldValues: fieldValues.map((fv, i) => (i === index ? { ...fv, ...patch } : fv)) });
|
||||
};
|
||||
|
||||
const addFieldValue = (): void => {
|
||||
setForm({ ...form, fieldValues: [...fieldValues, { fieldName: '', value: '' }] });
|
||||
};
|
||||
|
||||
const removeFieldValue = (index: number): void => {
|
||||
setForm({ ...form, fieldValues: fieldValues.filter((_, i) => i !== index) });
|
||||
};
|
||||
|
||||
const run = (): void => {
|
||||
if (!requestTypeId) {
|
||||
resolved.fail(texts.resolve.validationRequestType);
|
||||
return;
|
||||
}
|
||||
const conditions = fieldValues.filter(fv => fv.fieldName.trim() || fv.value.trim());
|
||||
if (conditions.some(fv => !fv.fieldName.trim() || !fv.value.trim())) {
|
||||
resolved.fail(texts.resolve.validationConditionField);
|
||||
return;
|
||||
}
|
||||
|
||||
resolved.resolve({
|
||||
requestTypeId: Number(requestTypeId),
|
||||
userEmail: userEmail.trim() || undefined,
|
||||
projectId: projectId.trim() || undefined,
|
||||
status
|
||||
status,
|
||||
fieldValues: conditions.length > 0 ? conditions : undefined
|
||||
});
|
||||
};
|
||||
|
||||
@@ -83,7 +133,7 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
||||
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||
placeholder={texts.resolve.requestTypePlaceholder}
|
||||
value={requestTypeId}
|
||||
onChange={e => setRequestTypeId(e.target.value)}
|
||||
onChange={e => setForm({ ...form, requestTypeId: e.target.value, fieldValues: [] })}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
@@ -99,7 +149,7 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
||||
placeholder={texts.resolve.userEmailPlaceholder}
|
||||
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||
emptyMessage={texts.select.employeesEmpty}
|
||||
onChange={setUserEmail}
|
||||
onChange={userEmail => setForm({ ...form, userEmail })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.resolve.projectLabel} hint={texts.resolve.projectHint}>
|
||||
@@ -115,16 +165,62 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
||||
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 : '');
|
||||
}}
|
||||
onChange={(nextId, item) => setForm({ ...form, projectId: nextId, projectLabel: item ? item.name : '' })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.resolve.statusLabel} required>
|
||||
<Select options={STATUS_OPTIONS} value={status} onChange={e => setStatus(e.target.value as RouteStepStatus)} />
|
||||
<Select
|
||||
options={STATUS_OPTIONS}
|
||||
value={status}
|
||||
onChange={e => setForm({ ...form, status: e.target.value as RouteStepStatus })}
|
||||
/>
|
||||
</Field>
|
||||
</FormGrid>
|
||||
|
||||
<div className={styles.conditions}>
|
||||
<div className={styles.conditionsHeader}>
|
||||
<span className={styles.conditionsTitle}>{texts.resolve.conditionsLabel}</span>
|
||||
{requestTypeId && !hasListName && (
|
||||
<span className={styles.conditionsHint}>{texts.resolve.conditionsDisabledHint}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasListName && (
|
||||
<>
|
||||
{fieldValues.map((fv, index) => {
|
||||
const field = sharePointFields.items.find(f => f.internalName === fv.fieldName);
|
||||
const isNumeric = field ? isNumericFieldType(field.typeAsString) : false;
|
||||
return (
|
||||
// eslint-disable-next-line react/no-array-index-key -- строки не переупорядочиваются, только добавляются/удаляются с конца или явным индексом
|
||||
<div className={styles.conditionRow} key={index}>
|
||||
<Select
|
||||
options={fieldNameOptions}
|
||||
value={fv.fieldName}
|
||||
disabled={sharePointFields.isLoading}
|
||||
placeholder={
|
||||
sharePointFields.isLoading
|
||||
? texts.resolve.conditionFieldLoading
|
||||
: texts.resolve.conditionFieldPlaceholder
|
||||
}
|
||||
onChange={e => updateFieldValue(index, { fieldName: e.target.value })}
|
||||
/>
|
||||
<TextInput
|
||||
type={isNumeric ? 'number' : 'text'}
|
||||
value={fv.value}
|
||||
placeholder={texts.resolve.conditionValuePlaceholder}
|
||||
onChange={e => updateFieldValue(index, { value: e.target.value })}
|
||||
/>
|
||||
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => removeFieldValue(index)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button variant="secondary" size="sm" icon="plus" onClick={addFieldValue}>
|
||||
{texts.resolve.conditionAdd}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.runButtonRow}>
|
||||
<Button variant="accent" icon="search" onClick={run} disabled={resolved.isBusy}>
|
||||
{resolved.isBusy ? texts.resolve.running : texts.resolve.run}
|
||||
@@ -137,7 +233,7 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
||||
{result && (
|
||||
<>
|
||||
<Alert kind="ok">
|
||||
{texts.resolve.summary(result.routeId, routeTypeLabels[result.routeType], result.requiresControl)}
|
||||
{texts.resolve.summary(result.routeId, result.requiresControl)}
|
||||
</Alert>
|
||||
{unresolvedCount > 0 && <Alert kind="warn">{texts.resolve.unresolved(unresolvedCount)}</Alert>}
|
||||
<DataTable
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { Company, Department, RequestType, RouteType, TemplateRoute } from '../../models';
|
||||
import { routeTypeLabels, texts } from '../../texts';
|
||||
import { Company, Department, RequestType, TemplateRoute } from '../../models';
|
||||
import { texts } from '../../texts';
|
||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { Checkbox, Field, FormGrid, FormModal, SearchableSelect, Select } from '../ui';
|
||||
@@ -21,22 +21,16 @@ export interface IRouteFormModalProps {
|
||||
|
||||
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,
|
||||
@@ -45,8 +39,7 @@ function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: s
|
||||
}
|
||||
return {
|
||||
requestTypeId: String(editing.requestTypeId),
|
||||
routeType: editing.routeType,
|
||||
companyId: editing.companyId,
|
||||
companyId: editing.companyId ?? '',
|
||||
departmentId: editing.departmentId ?? '',
|
||||
byProject: editing.byProject,
|
||||
requiresControl: editing.requiresControl
|
||||
@@ -80,24 +73,19 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
|
||||
submit.fail(texts.routes.validationRequestType);
|
||||
return;
|
||||
}
|
||||
if (!form.companyId.trim()) {
|
||||
submit.fail(texts.routes.validationCompany);
|
||||
return;
|
||||
}
|
||||
|
||||
const companyId = form.companyId.trim() ? form.companyId.trim() : null;
|
||||
const departmentId = form.departmentId.trim() ? form.departmentId.trim() : null;
|
||||
const request = editing
|
||||
? api.templateRoutes.update(editing.id, {
|
||||
routeType: form.routeType,
|
||||
companyId: form.companyId.trim(),
|
||||
companyId,
|
||||
departmentId,
|
||||
byProject: form.byProject,
|
||||
requiresControl: form.requiresControl
|
||||
})
|
||||
: api.templateRoutes.create({
|
||||
routeType: form.routeType,
|
||||
requestTypeId: Number(form.requestTypeId),
|
||||
companyId: form.companyId.trim(),
|
||||
companyId,
|
||||
departmentId,
|
||||
byProject: form.byProject,
|
||||
requiresControl: form.requiresControl
|
||||
@@ -129,18 +117,13 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
|
||||
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}>
|
||||
<Field label={texts.routes.companyLabel} full hint={texts.routes.companyHint}>
|
||||
<SearchableSelect
|
||||
options={companies.map(c => ({ value: c.id, label: c.name }))}
|
||||
value={form.companyId}
|
||||
loading={dictionaryLoading}
|
||||
allowClear
|
||||
clearLabel={texts.routes.companyClear}
|
||||
placeholder={texts.routes.companyPlaceholder}
|
||||
searchPlaceholder={texts.routes.companySearchPlaceholder}
|
||||
onChange={companyId =>
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
ComparisonOperator,
|
||||
Employee,
|
||||
HeadType,
|
||||
ProjectRole,
|
||||
RequestType,
|
||||
RouteStepStatus,
|
||||
StepExecutorKind,
|
||||
TemplateRoute,
|
||||
TemplateRouteStep
|
||||
} from '../../models';
|
||||
import { headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts';
|
||||
import { comparisonOperatorLabels, headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts';
|
||||
import { detectExecutorKind } from '../../utils/stepExecutor';
|
||||
import { toEmployeeOption } from '../../utils/employee';
|
||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||
import { useSharePointFields } from '../../hooks/useSharePointFields';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
|
||||
import { Checkbox, Field, FormGrid, FormModal, RadioGroup, SearchableSelect, Select, TextInput } from '../ui';
|
||||
|
||||
export interface IRouteStepFormModalProps {
|
||||
api: ApprovalRouteApi;
|
||||
route: TemplateRoute;
|
||||
/** Undefined, пока грузится тип заявки маршрута. */
|
||||
requestType?: RequestType;
|
||||
sharePoint: SharePointListsClient;
|
||||
/** Undefined — создание нового шага, иначе редактирование существующего. */
|
||||
editing?: TemplateRouteStep;
|
||||
nextOrderNo: number;
|
||||
@@ -36,6 +43,10 @@ interface IFormState {
|
||||
projectRole: ProjectRole;
|
||||
headType: HeadType;
|
||||
allowReassignToOtherApprovers: boolean;
|
||||
hasCondition: boolean;
|
||||
fieldName: string;
|
||||
operation: ComparisonOperator;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
|
||||
@@ -49,6 +60,10 @@ const HEAD_TYPE_OPTIONS = Object.values(HeadType)
|
||||
.filter(v => v !== HeadType.None)
|
||||
.map(v => ({ value: v, label: headTypeLabels[v] }));
|
||||
|
||||
const OPERATION_OPTIONS = Object.values(ComparisonOperator)
|
||||
.filter(v => v !== ComparisonOperator.None)
|
||||
.map(v => ({ value: v, label: comparisonOperatorLabels[v] }));
|
||||
|
||||
function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState {
|
||||
if (!editing) {
|
||||
return {
|
||||
@@ -59,7 +74,11 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
|
||||
position: '',
|
||||
projectRole: ProjectRole.ProjectManager,
|
||||
headType: HeadType.Chief,
|
||||
allowReassignToOtherApprovers: false
|
||||
allowReassignToOtherApprovers: false,
|
||||
hasCondition: false,
|
||||
fieldName: '',
|
||||
operation: ComparisonOperator.GreaterThan,
|
||||
value: ''
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -70,7 +89,11 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
|
||||
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
|
||||
allowReassignToOtherApprovers: editing.allowReassignToOtherApprovers,
|
||||
hasCondition: Boolean(editing.fieldName),
|
||||
fieldName: editing.fieldName ?? '',
|
||||
operation: editing.operation && editing.operation !== ComparisonOperator.None ? editing.operation : ComparisonOperator.GreaterThan,
|
||||
value: editing.value ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,6 +101,8 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
|
||||
export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
api,
|
||||
route,
|
||||
requestType,
|
||||
sharePoint,
|
||||
editing,
|
||||
nextOrderNo,
|
||||
employees,
|
||||
@@ -87,6 +112,35 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
}) => {
|
||||
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo));
|
||||
const submit = useAsyncSubmit();
|
||||
const hasListName = Boolean(requestType?.listName);
|
||||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl, requestType?.listName);
|
||||
|
||||
const fieldNameOptions = React.useMemo(() => {
|
||||
const options = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
|
||||
if (form.fieldName && !options.some(o => o.value === form.fieldName)) {
|
||||
options.unshift({ value: form.fieldName, label: form.fieldName });
|
||||
}
|
||||
return options;
|
||||
}, [sharePointFields.items, form.fieldName]);
|
||||
|
||||
// Тип столбца известен, только если он есть среди подгруженных полей списка —
|
||||
// для значения, введённого до появления поля в списке (или после его удаления),
|
||||
// ограничения ниже не действуют, чтобы не блокировать уже сохранённые данные.
|
||||
const selectedField = sharePointFields.items.find(f => f.internalName === form.fieldName);
|
||||
const isNumericField = selectedField ? isNumericFieldType(selectedField.typeAsString) : true;
|
||||
|
||||
const operationOptions = isNumericField
|
||||
? OPERATION_OPTIONS
|
||||
: OPERATION_OPTIONS.filter(o => o.value === ComparisonOperator.Equal);
|
||||
|
||||
// Строковая колонка допускает только «равно» — если пользователь сменил
|
||||
// field_name на нечисловую колонку, а раньше был выбран «больше»/«меньше», сбрасываем.
|
||||
React.useEffect(() => {
|
||||
if (!isNumericField && form.operation !== ComparisonOperator.Equal) {
|
||||
setForm(f => ({ ...f, operation: ComparisonOperator.Equal }));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isNumericField]);
|
||||
|
||||
const submitForm = (): void => {
|
||||
const orderNo = Number(form.orderNo);
|
||||
@@ -102,6 +156,14 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
submit.fail(texts.routeSteps.validationPosition);
|
||||
return;
|
||||
}
|
||||
if (form.hasCondition && (!form.fieldName.trim() || !form.value.trim())) {
|
||||
submit.fail(texts.routeSteps.validationCondition);
|
||||
return;
|
||||
}
|
||||
if (form.hasCondition && isNumericField && !Number.isFinite(Number(form.value.trim()))) {
|
||||
submit.fail(texts.routeSteps.validationConditionNumber);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
orderNo,
|
||||
@@ -110,7 +172,10 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
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
|
||||
headType: form.kind === 'headType' ? form.headType : null,
|
||||
fieldName: form.hasCondition ? form.fieldName.trim() : null,
|
||||
operation: form.hasCondition ? form.operation : null,
|
||||
value: form.hasCondition ? form.value.trim() : null
|
||||
};
|
||||
|
||||
const request = editing
|
||||
@@ -217,6 +282,55 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
onChange={v => setForm({ ...form, allowReassignToOtherApprovers: v })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={texts.routeSteps.conditionLabel} full hint={!hasListName ? texts.routeSteps.conditionDisabledHint : undefined}>
|
||||
<Checkbox
|
||||
label={texts.routeSteps.conditionCheckbox}
|
||||
checked={form.hasCondition}
|
||||
disabled={!hasListName}
|
||||
onChange={v => setForm({ ...form, hasCondition: v })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{form.hasCondition && hasListName && (
|
||||
<>
|
||||
<Field
|
||||
label={texts.routeSteps.conditionFieldNameLabel}
|
||||
required
|
||||
full
|
||||
hint={sharePointFields.error ? texts.routeSteps.conditionFieldNameError(sharePointFields.error) : undefined}
|
||||
>
|
||||
<Select
|
||||
options={fieldNameOptions}
|
||||
value={form.fieldName}
|
||||
disabled={sharePointFields.isLoading}
|
||||
placeholder={
|
||||
sharePointFields.isLoading
|
||||
? texts.routeSteps.conditionFieldNameLoading
|
||||
: fieldNameOptions.length === 0
|
||||
? texts.routeSteps.conditionFieldNameEmpty
|
||||
: texts.routeSteps.conditionFieldNamePlaceholder
|
||||
}
|
||||
onChange={e => setForm({ ...form, fieldName: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.routeSteps.conditionOperationLabel} required>
|
||||
<Select
|
||||
options={operationOptions}
|
||||
value={form.operation}
|
||||
onChange={e => setForm({ ...form, operation: e.target.value as ComparisonOperator })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.routeSteps.conditionValueLabel} required>
|
||||
<TextInput
|
||||
type={isNumericField ? 'number' : 'text'}
|
||||
value={form.value}
|
||||
onChange={e => setForm({ ...form, value: e.target.value })}
|
||||
placeholder={texts.routeSteps.conditionValuePlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</FormGrid>
|
||||
</FormModal>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,10 @@ 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 { SharePointListsClient } from '../../services/sharePointLists';
|
||||
import { RequestType, RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models';
|
||||
import { routeStepStatusLabels, texts } from '../../texts';
|
||||
import { describeStepCondition, describeStepExecutor } from '../../utils/stepExecutor';
|
||||
import { useRouteSteps } from '../../hooks/useRouteSteps';
|
||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||
@@ -25,16 +26,19 @@ import {
|
||||
export interface IRouteStepsScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
route: TemplateRoute;
|
||||
requestTypeName: string;
|
||||
/** Undefined, пока грузится тип заявки (см. AdminApp.openSteps). */
|
||||
requestType?: RequestType;
|
||||
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||
dictionary?: PortalDictionaryClient;
|
||||
sharePoint: SharePointListsClient;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route, requestTypeName, dictionary, onBack }) => {
|
||||
export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route, requestType, dictionary, sharePoint, onBack }) => {
|
||||
const steps = useRouteSteps(api, route.id);
|
||||
const employees = usePortalEmployees(dictionary);
|
||||
const [savedWarning, setSavedWarning] = React.useState<string | undefined>();
|
||||
const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`;
|
||||
|
||||
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
|
||||
const [showForm, setShowForm] = React.useState(false);
|
||||
@@ -72,7 +76,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
||||
/>
|
||||
<PageHeader
|
||||
icon="flag-check"
|
||||
subtitle={texts.routeSteps.subtitle(requestTypeName, routeTypeLabels[route.routeType])}
|
||||
subtitle={texts.routeSteps.subtitle(requestTypeName)}
|
||||
title={texts.routeSteps.title(route.id)}
|
||||
actions={
|
||||
<>
|
||||
@@ -106,7 +110,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
||||
key: 'status',
|
||||
header: texts.routeSteps.columnStatus,
|
||||
render: r => (
|
||||
<Badge variant={r.status === RouteStepStatus.Approval ? 'gold' : 'blue'}>
|
||||
<Badge variant={r.status === RouteStepStatus.Approval ? 'neutral' : 'blue'}>
|
||||
{routeStepStatusLabels[r.status]}
|
||||
</Badge>
|
||||
)
|
||||
@@ -117,6 +121,11 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
||||
align: 'center',
|
||||
render: r => (r.allowReassignToOtherApprovers ? <Badge variant="ok">{texts.routeSteps.reassignAllowed}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>)
|
||||
},
|
||||
{
|
||||
key: 'condition',
|
||||
header: texts.routeSteps.columnCondition,
|
||||
render: r => describeStepCondition(r) ?? texts.common.empty
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
@@ -140,6 +149,8 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
||||
<RouteStepFormModal
|
||||
api={api}
|
||||
route={route}
|
||||
requestType={requestType}
|
||||
sharePoint={sharePoint}
|
||||
editing={editing}
|
||||
nextOrderNo={nextOrderNo}
|
||||
employees={employees.items}
|
||||
|
||||
@@ -3,8 +3,8 @@ 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 { TemplateRoute } from '../../models';
|
||||
import { texts } from '../../texts';
|
||||
import { shortGuid } from '../../utils/guid';
|
||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
|
||||
@@ -92,12 +92,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, on
|
||||
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: 'company', header: texts.routes.columnCompany, render: r => (r.companyId ? companyName(r.companyId) : texts.common.empty) },
|
||||
{ key: 'department', header: texts.routes.columnDepartment, render: r => (r.departmentId ? departmentName(r.departmentId) : texts.common.empty) },
|
||||
{
|
||||
key: 'byProject',
|
||||
|
||||
Reference in New Issue
Block a user