Заглушка для департамента (#8)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/8
This commit was merged in pull request #8.
This commit is contained in:
@@ -8,6 +8,7 @@ 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';
|
||||
@@ -56,6 +57,7 @@ interface IResolveFormState {
|
||||
projectLabel: string;
|
||||
status: RouteStepStatus;
|
||||
fieldValues: ResolveFieldValue[];
|
||||
departmentId: string;
|
||||
}
|
||||
|
||||
const EMPTY_RESOLVE_FORM: IResolveFormState = {
|
||||
@@ -64,13 +66,14 @@ const EMPTY_RESOLVE_FORM: IResolveFormState = {
|
||||
projectId: '',
|
||||
projectLabel: '',
|
||||
status: RouteStepStatus.Approval,
|
||||
fieldValues: []
|
||||
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 = [] } = form;
|
||||
const { requestTypeId, userEmail, projectId, projectLabel, status, fieldValues = [], departmentId } = form;
|
||||
|
||||
// Открыли по глубокой ссылке (aartype и т.д. в URL) — параметры оттуда
|
||||
// перекрывают то, что было в localStorage. Только на монтировании: route
|
||||
@@ -85,7 +88,8 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
||||
projectId: route.projectId,
|
||||
projectLabel: route.projectLabel,
|
||||
status: route.status,
|
||||
fieldValues: route.fieldValues
|
||||
fieldValues: route.fieldValues,
|
||||
departmentId: route.departmentId
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -94,8 +98,11 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
||||
// которую можно скопировать/сохранить. replaceRoute, а не navigate: иначе
|
||||
// каждый введённый символ добавлял бы шаг в историю браузера.
|
||||
React.useEffect(() => {
|
||||
replaceRoute({ tab: 'resolve', resolve: { requestTypeId, userEmail, projectId, projectLabel, status, fieldValues } });
|
||||
}, [requestTypeId, userEmail, projectId, projectLabel, status, fieldValues]);
|
||||
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);
|
||||
@@ -104,6 +111,9 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
||||
|
||||
const resolved = useResolveRoute(api);
|
||||
const employees = usePortalEmployees(dictionary);
|
||||
// ~700 отделов — то же соображение, что и в RouteFormModal: дешевле грузить
|
||||
// список один раз и искать по нему на клиенте, чем ходить в сеть на каждый символ.
|
||||
const dictionaries = usePortalDictionaries(dictionary);
|
||||
|
||||
const searchProjects = React.useCallback(
|
||||
(query: string, page: number) => {
|
||||
@@ -143,7 +153,8 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
||||
userEmail: userEmail.trim() || undefined,
|
||||
projectId: projectId.trim() || undefined,
|
||||
status,
|
||||
fieldValues: conditions.length > 0 ? conditions : undefined
|
||||
fieldValues: conditions.length > 0 ? conditions : undefined,
|
||||
departmentId: departmentId.trim() || undefined
|
||||
});
|
||||
};
|
||||
|
||||
@@ -197,6 +208,19 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
||||
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}
|
||||
|
||||
@@ -20,7 +20,11 @@ export enum HeadType {
|
||||
None = 'None',
|
||||
Chief = 'Chief',
|
||||
DirectionHead = 'DirectionHead',
|
||||
DirectorTansu = 'DirectorTansu'
|
||||
DirectorTansu = 'DirectorTansu',
|
||||
// Резолвится по departmentId из запроса на resolve (см. ResolveTemplateRouteQuery),
|
||||
// а не по цепочке подчинённости инициатора, как остальные типы, — поэтому на
|
||||
// экране «Проверка маршрута» появляется отдельное поле выбора отдела.
|
||||
DepartmentHead = 'DepartmentHead'
|
||||
}
|
||||
|
||||
export enum ComparisonOperator {
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface ResolveTemplateRouteQuery {
|
||||
projectId?: string;
|
||||
status: RouteStepStatus;
|
||||
fieldValues?: ResolveFieldValue[];
|
||||
/** Для шагов с HeadType.DepartmentHead — отдел не выводится из инициатора, задаётся явно. */
|
||||
departmentId?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedRouteStep {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { RouteStepStatus } from './models';
|
||||
import { navigate, parseRoute } from './routing';
|
||||
|
||||
// buildQuery/navigate читают/пишут window.location через history API — сбрасываем
|
||||
@@ -50,6 +51,16 @@ describe('parseRoute', () => {
|
||||
it('resolve не собирается без requestTypeId, даже если tab=resolve', () => {
|
||||
expect(parseRoute('?aatab=resolve').resolve).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolve.departmentId читается из aadept (шаг «Руководитель указанного отдела»)', () => {
|
||||
const route = parseRoute('?aatab=resolve&aartype=1&aadept=00000000-0000-0000-0000-0000000000aa');
|
||||
expect(route.resolve?.departmentId).toBe('00000000-0000-0000-0000-0000000000aa');
|
||||
});
|
||||
|
||||
it('resolve.departmentId пустой, если aadept не передан', () => {
|
||||
const route = parseRoute('?aatab=resolve&aartype=1');
|
||||
expect(route.resolve?.departmentId).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigate', () => {
|
||||
@@ -71,4 +82,21 @@ describe('navigate', () => {
|
||||
navigate({ tab: 'requestTypes' });
|
||||
expect(window.history.length).toBe(initialLength + 1);
|
||||
});
|
||||
|
||||
it('round-trip для resolve.departmentId', () => {
|
||||
navigate({
|
||||
tab: 'resolve',
|
||||
resolve: {
|
||||
requestTypeId: '1',
|
||||
userEmail: '',
|
||||
projectId: '',
|
||||
projectLabel: '',
|
||||
status: RouteStepStatus.Approval,
|
||||
fieldValues: [],
|
||||
departmentId: '00000000-0000-0000-0000-0000000000aa'
|
||||
}
|
||||
});
|
||||
const roundTripped = parseRoute(window.location.search);
|
||||
expect(roundTripped.resolve?.departmentId).toBe('00000000-0000-0000-0000-0000000000aa');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,13 @@ export interface IResolveRouteParams {
|
||||
projectLabel: string;
|
||||
status: RouteStepStatus;
|
||||
fieldValues: ResolveFieldValue[];
|
||||
/**
|
||||
* Для шагов с HeadType.DepartmentHead (см. ResolveTemplateRouteQuery.departmentId).
|
||||
* Без отдельного departmentLabel — в отличие от projectLabel (AsyncSearchableSelect,
|
||||
* пагинированный поиск), отделы выбираются из уже целиком загруженного справочника
|
||||
* (SearchableSelect), так что подпись резолвится на месте по id, а не хранится отдельно.
|
||||
*/
|
||||
departmentId: string;
|
||||
}
|
||||
|
||||
export interface IManageRequestRouteParams {
|
||||
@@ -50,7 +57,7 @@ const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'ma
|
||||
|
||||
const PARAM_KEYS = [
|
||||
'aatab', 'aaroute',
|
||||
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields',
|
||||
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept',
|
||||
'aamtype', 'aamreq'
|
||||
];
|
||||
|
||||
@@ -92,7 +99,8 @@ export function parseRoute(search: string = window.location.search): IAdminRoute
|
||||
projectId: params.get('aaproject') || '',
|
||||
projectLabel: params.get('aaprojectname') || '',
|
||||
status: (params.get('aastatus') as RouteStepStatus) || RouteStepStatus.Approval,
|
||||
fieldValues: parseFieldValues(params.get('aafields'))
|
||||
fieldValues: parseFieldValues(params.get('aafields')),
|
||||
departmentId: params.get('aadept') || ''
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -130,6 +138,7 @@ function buildQuery(route: IAdminRoute): string {
|
||||
if (route.resolve.projectLabel) params.set('aaprojectname', route.resolve.projectLabel);
|
||||
if (route.resolve.status) params.set('aastatus', route.resolve.status);
|
||||
if (route.resolve.fieldValues.length > 0) params.set('aafields', JSON.stringify(route.resolve.fieldValues));
|
||||
if (route.resolve.departmentId) params.set('aadept', route.resolve.departmentId);
|
||||
}
|
||||
|
||||
if (route.tab === 'manageRequests' && route.manage && route.manage.requestTypeId && route.manage.requestId) {
|
||||
|
||||
@@ -29,7 +29,8 @@ export const headTypeLabels: Record<HeadType, string> = {
|
||||
[HeadType.None]: '—',
|
||||
[HeadType.Chief]: 'Непосредственный руководитель',
|
||||
[HeadType.DirectionHead]: 'Руководитель направления',
|
||||
[HeadType.DirectorTansu]: 'Директор Tansu'
|
||||
[HeadType.DirectorTansu]: 'Директор Tansu',
|
||||
[HeadType.DepartmentHead]: 'Руководитель указанного отдела'
|
||||
};
|
||||
|
||||
export const comparisonOperatorLabels: Record<ComparisonOperator, string> = {
|
||||
@@ -246,6 +247,12 @@ export const texts = {
|
||||
projectSearchPlaceholder: 'Поиск проекта по названию...',
|
||||
projectClear: 'Без проекта',
|
||||
projectDictionaryLoading: 'Справочник проектов ещё загружается.',
|
||||
departmentLabel: 'Отдел (для шага «Руководитель указанного отдела»)',
|
||||
departmentHint: 'Заполните, если в маршруте есть шаг с типом руководителя «Руководитель указанного отдела».',
|
||||
departmentPlaceholder: 'Не выбран',
|
||||
departmentSearchPlaceholder: 'Поиск по названию отдела...',
|
||||
departmentClear: 'Без отдела',
|
||||
departmentEmpty: 'Отделы не найдены',
|
||||
statusLabel: 'Статус',
|
||||
conditionsLabel: 'Условия (значения полей заявки)',
|
||||
conditionsDisabledHint: 'Недоступно: у выбранного типа заявки не указан список SharePoint (list_name).',
|
||||
|
||||
Reference in New Issue
Block a user