Files
tnsu-approval-route/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.tsx
T
gorbenko.pavel 1d45780294
Tests / frontend-test (push) Successful in 43s
Tests / backend-test (push) Successful in 2m57s
Заглушка для департамента (#8)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/8
2026-08-07 10:36:53 +00:00

335 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 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>
);
};