1d45780294
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/8
335 lines
14 KiB
TypeScript
335 lines
14 KiB
TypeScript
import * as React from 'react';
|
||
import styles from './ResolveScreen.module.scss';
|
||
import { ApprovalRouteApi } from '../../services/api';
|
||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||
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 { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
||
import { useSharePointFields } from '../../hooks/useSharePointFields';
|
||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
||
import { IResolveRouteParams, replaceRoute } from '../../routing';
|
||
import {
|
||
Alert,
|
||
AsyncSearchableSelect,
|
||
Breadcrumbs,
|
||
Button,
|
||
CellNote,
|
||
DataTable,
|
||
Field,
|
||
FormGrid,
|
||
IconButton,
|
||
PageHeader,
|
||
Panel,
|
||
PrimaryCell,
|
||
SearchableSelect,
|
||
Select,
|
||
TextInput
|
||
} from '../ui';
|
||
|
||
export interface IResolveScreenProps {
|
||
api: ApprovalRouteApi;
|
||
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||
dictionary?: PortalDictionaryClient;
|
||
sharePoint: SharePointListsClient;
|
||
currentUserEmail?: string;
|
||
/** Заполнение из URL (глубокая ссылка) — есть, только если в адресе заданы
|
||
* параметры проверки маршрута (aartype и т.д., см. routing.ts). */
|
||
route?: IResolveRouteParams;
|
||
}
|
||
|
||
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
|
||
.map(v => ({ value: v, label: routeStepStatusLabels[v] }));
|
||
|
||
// Форма проверки маршрута заполняется одними и теми же параметрами раз за разом
|
||
// (тип заявки, инициатор, проект, статус) — персистим её в localStorage, чтобы
|
||
// не вводить всё заново при каждом открытии экрана.
|
||
const RESOLVE_FORM_STORAGE_KEY = 'approvalAdmin.resolveForm';
|
||
|
||
interface IResolveFormState {
|
||
requestTypeId: string;
|
||
userEmail: string;
|
||
projectId: string;
|
||
projectLabel: string;
|
||
status: RouteStepStatus;
|
||
fieldValues: ResolveFieldValue[];
|
||
departmentId: string;
|
||
}
|
||
|
||
const EMPTY_RESOLVE_FORM: IResolveFormState = {
|
||
requestTypeId: '',
|
||
userEmail: '',
|
||
projectId: '',
|
||
projectLabel: '',
|
||
status: RouteStepStatus.Approval,
|
||
fieldValues: [],
|
||
departmentId: ''
|
||
};
|
||
|
||
export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary, sharePoint, currentUserEmail, route }) => {
|
||
const requestTypes = useRequestTypes(api);
|
||
const [form, setForm] = useLocalStorageState<IResolveFormState>(RESOLVE_FORM_STORAGE_KEY, EMPTY_RESOLVE_FORM);
|
||
const { requestTypeId, userEmail, projectId, projectLabel, status, fieldValues = [], departmentId } = form;
|
||
|
||
// Открыли по глубокой ссылке (aartype и т.д. в URL) — параметры оттуда
|
||
// перекрывают то, что было в localStorage. Только на монтировании: route
|
||
// меняется при каждом собственном же replaceRoute ниже, а не только при
|
||
// переходе по внешней ссылке — сравнивать его на равенство и держать в
|
||
// зависимостях смысла нет.
|
||
React.useEffect(() => {
|
||
if (!route) return;
|
||
setForm({
|
||
requestTypeId: route.requestTypeId,
|
||
userEmail: route.userEmail,
|
||
projectId: route.projectId,
|
||
projectLabel: route.projectLabel,
|
||
status: route.status,
|
||
fieldValues: route.fieldValues,
|
||
departmentId: route.departmentId
|
||
});
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// Держим URL синхронным с формой — правки сразу становятся частью ссылки,
|
||
// которую можно скопировать/сохранить. replaceRoute, а не navigate: иначе
|
||
// каждый введённый символ добавлял бы шаг в историю браузера.
|
||
React.useEffect(() => {
|
||
replaceRoute({
|
||
tab: 'resolve',
|
||
resolve: { requestTypeId, userEmail, projectId, projectLabel, status, fieldValues, departmentId }
|
||
});
|
||
}, [requestTypeId, userEmail, projectId, projectLabel, status, fieldValues, departmentId]);
|
||
|
||
const requestType = requestTypes.items.find(rt => rt.id === Number(requestTypeId));
|
||
const hasListId = Boolean(requestType?.listId);
|
||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined);
|
||
const fieldNameOptions = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
|
||
|
||
const resolved = useResolveRoute(api);
|
||
const employees = usePortalEmployees(dictionary);
|
||
// ~700 отделов — то же соображение, что и в RouteFormModal: дешевле грузить
|
||
// список один раз и искать по нему на клиенте, чем ходить в сеть на каждый символ.
|
||
const dictionaries = usePortalDictionaries(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 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,
|
||
fieldValues: conditions.length > 0 ? conditions : undefined,
|
||
departmentId: departmentId.trim() || undefined
|
||
});
|
||
};
|
||
|
||
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 => setForm({ ...form, requestTypeId: e.target.value, fieldValues: [] })}
|
||
/>
|
||
</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={userEmail => setForm({ ...form, userEmail })}
|
||
/>
|
||
</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) => setForm({ ...form, projectId: nextId, projectLabel: item ? item.name : '' })}
|
||
/>
|
||
</Field>
|
||
<Field label={texts.resolve.departmentLabel} hint={texts.resolve.departmentHint}>
|
||
<SearchableSelect
|
||
options={dictionaries.departments.map(d => ({ value: d.id, label: d.name, sublabel: d.company }))}
|
||
value={departmentId}
|
||
loading={dictionaries.isLoading}
|
||
allowClear
|
||
clearLabel={texts.resolve.departmentClear}
|
||
placeholder={texts.resolve.departmentPlaceholder}
|
||
searchPlaceholder={texts.resolve.departmentSearchPlaceholder}
|
||
emptyMessage={texts.resolve.departmentEmpty}
|
||
onChange={departmentId => setForm({ ...form, departmentId })}
|
||
/>
|
||
</Field>
|
||
<Field label={texts.resolve.statusLabel} required>
|
||
<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 && !hasListId && (
|
||
<span className={styles.conditionsHint}>{texts.resolve.conditionsDisabledHint}</span>
|
||
)}
|
||
</div>
|
||
|
||
{hasListId && (
|
||
<>
|
||
{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}
|
||
</Button>
|
||
</div>
|
||
</Panel>
|
||
|
||
{resolved.error && <Alert kind="err">{resolved.error}</Alert>}
|
||
|
||
{result && (
|
||
<>
|
||
<Alert kind="ok">
|
||
{texts.resolve.summary(result.routeId, 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 ?? undefined);
|
||
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>
|
||
);
|
||
};
|