много правок
This commit is contained in:
@@ -153,7 +153,7 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
|||||||
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} />
|
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} />
|
||||||
)}
|
)}
|
||||||
{route.tab === 'manageByUser' && (
|
{route.tab === 'manageByUser' && (
|
||||||
<ManageByUserScreen api={api} dictionary={dictionary} requestManage={requestManage} />
|
<ManageByUserScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manageByUser} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { texts } from '../../texts';
|
||||||
|
import { Button, Modal } from '../ui';
|
||||||
|
|
||||||
|
export type DuplicateChoice = 'delete' | 'skip';
|
||||||
|
|
||||||
|
export interface IManageByUserDuplicateModalProps {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
onChoice: (choice: DuplicateChoice, forAll: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Да / Да для всех / Нет / Нет для всех» — классический диалог разрешения
|
||||||
|
* конфликтов при пакетной операции (как в проводнике при копировании файлов).
|
||||||
|
* Закрытие крестиком/подложкой равносильно «Нет» (без «для всех» — иначе
|
||||||
|
* случайный клик мимо тихо остановил бы весь оставшийся список).
|
||||||
|
*/
|
||||||
|
export const ManageByUserDuplicateModal: React.FC<IManageByUserDuplicateModalProps> = ({ title, message, onChoice }) => (
|
||||||
|
<Modal
|
||||||
|
title={title}
|
||||||
|
onClose={() => onChoice('skip', false)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={() => onChoice('skip', false)}>{texts.manageByUser.duplicateSkip}</Button>
|
||||||
|
<Button variant="secondary" onClick={() => onChoice('skip', true)}>{texts.manageByUser.duplicateSkipAll}</Button>
|
||||||
|
<Button variant="danger" icon="trash" onClick={() => onChoice('delete', false)}>{texts.manageByUser.duplicateDelete}</Button>
|
||||||
|
<Button variant="danger" icon="trash" onClick={() => onChoice('delete', true)}>{texts.manageByUser.duplicateDeleteAll}</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p>{message}</p>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './ManageByUserScreen.module.scss';
|
import styles from './ManageByUserScreen.module.scss';
|
||||||
import { ManageByUserReplaceModal } from './ManageByUserReplaceModal';
|
import { ManageByUserReplaceModal } from './ManageByUserReplaceModal';
|
||||||
|
import { DuplicateChoice, ManageByUserDuplicateModal } from './ManageByUserDuplicateModal';
|
||||||
import { ApprovalRouteApi } from '../../services/api';
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||||
import { RequestManageService } from '../../services/requestManageService';
|
import { RequestManageService } from '../../services/requestManageService';
|
||||||
@@ -11,6 +12,8 @@ import { shortGuid } from '../../utils/guid';
|
|||||||
import { describeStepCondition } from '../../utils/stepExecutor';
|
import { describeStepCondition } from '../../utils/stepExecutor';
|
||||||
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
||||||
import { reportError } from '../../utils/errors';
|
import { reportError } from '../../utils/errors';
|
||||||
|
import { IManageByUserRouteParams, replaceRoute } from '../../routing';
|
||||||
|
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||||
import { useRequestTypeListsAll } from '../../hooks/useRequestTypeListsAll';
|
import { useRequestTypeListsAll } from '../../hooks/useRequestTypeListsAll';
|
||||||
import { useTemplateRouteStepsByExecutor } from '../../hooks/useTemplateRouteStepsByExecutor';
|
import { useTemplateRouteStepsByExecutor } from '../../hooks/useTemplateRouteStepsByExecutor';
|
||||||
@@ -42,21 +45,27 @@ export interface IManageByUserScreenProps {
|
|||||||
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||||
dictionary?: PortalDictionaryClient;
|
dictionary?: PortalDictionaryClient;
|
||||||
requestManage: RequestManageService;
|
requestManage: RequestManageService;
|
||||||
|
/** Заполнение из URL (глубокая ссылка на конкретного сотрудника) — см. routing.ts. */
|
||||||
|
route?: IManageByUserRouteParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Выбранный сотрудник переживает перезагрузку страницы — как и выбор заявки в
|
||||||
|
// ManageRequestsScreen (тот же приём, отдельный ключ).
|
||||||
|
const SELECTION_STORAGE_KEY = 'approvalAdmin.manageByUserEmail';
|
||||||
|
|
||||||
// Активные статусы строки для этого экрана — сознательно уже, чем
|
// Активные статусы строки для этого экрана — сознательно уже, чем
|
||||||
// isEditableRowStatus (models/requestManage.ts): «Возвращена на доработку» и
|
// isEditableRowStatus (models/requestManage.ts): «Возвращена на доработку» и
|
||||||
// черновики сюда не входят (согласовано отдельно, см. план реализации).
|
// черновики сюда не входят (согласовано отдельно, см. план реализации).
|
||||||
const ACTIVE_STATUSES: string[] = [REQUEST_STATUS.queued, REQUEST_STATUS.onApproval, REQUEST_STATUS.onExecution];
|
const ACTIVE_STATUSES: string[] = [REQUEST_STATUS.queued, REQUEST_STATUS.onApproval, REQUEST_STATUS.onExecution];
|
||||||
|
|
||||||
type Settled<T> = { ok: true; value: T } | { ok: false; error: unknown };
|
// Итог пакетной операции по одной панели.
|
||||||
|
interface IBulkOutcome {
|
||||||
// Promise.allSettled недоступен в целевом lib этого проекта (tsconfig.json —
|
ok: number;
|
||||||
// es5/es2015.*, без es2020.promise) — свой аналог вместо него: массовая замена
|
fail: number;
|
||||||
// не должна падать целиком из-за одной сломанной строки/шага (см.
|
/** Дубли, снятые удалением старой строки/шага (пользователь выбрал «удалить»). */
|
||||||
// bulkReplaceLive/bulkReplaceTemplates ниже).
|
deleted: number;
|
||||||
function settle<T>(p: Promise<T>): Promise<Settled<T>> {
|
/** Дубли, оставленные как есть (пользователь выбрал «пропустить»). */
|
||||||
return p.then((value): Settled<T> => ({ ok: true, value })).catch((error: unknown): Settled<T> => ({ ok: false, error }));
|
skipped: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Та же цветовая группировка статуса заявки, что и в ManageRequestsScreen
|
// Та же цветовая группировка статуса заявки, что и в ManageRequestsScreen
|
||||||
@@ -76,13 +85,31 @@ interface IReplaceScope {
|
|||||||
templates: UserTemplateStepRow[];
|
templates: UserTemplateStepRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, dictionary, requestManage }) => {
|
export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, dictionary, requestManage, route }) => {
|
||||||
const requestTypes = useRequestTypes(api);
|
const requestTypes = useRequestTypes(api);
|
||||||
const listSeries = useRequestTypeListsAll(api);
|
const listSeries = useRequestTypeListsAll(api);
|
||||||
const employees = usePortalEmployees(dictionary);
|
const employees = usePortalEmployees(dictionary);
|
||||||
const dictionaries = usePortalDictionaries(dictionary);
|
const dictionaries = usePortalDictionaries(dictionary);
|
||||||
|
|
||||||
const [employeeEmail, setEmployeeEmail] = React.useState('');
|
const [employeeEmail, setEmployeeEmail] = useLocalStorageState<string>(SELECTION_STORAGE_KEY, '');
|
||||||
|
|
||||||
|
// Открыли по глубокой ссылке (aabuser в URL) — перекрывает то, что было в
|
||||||
|
// localStorage. Только на монтировании: route меняется при каждом собственном
|
||||||
|
// replaceRoute ниже (тот же приём, что в ManageRequestsScreen/ResolveScreen).
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (route?.userEmail) setEmployeeEmail(route.userEmail);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Держим URL синхронным с выбором — открытый экран можно скопировать и
|
||||||
|
// прислать коллеге. replaceRoute, а не navigate: смена сотрудника — правка
|
||||||
|
// состояния текущего экрана, а не отдельный шаг в истории браузера.
|
||||||
|
React.useEffect(() => {
|
||||||
|
replaceRoute({
|
||||||
|
tab: 'manageByUser',
|
||||||
|
manageByUser: employeeEmail ? { userEmail: employeeEmail } : undefined
|
||||||
|
});
|
||||||
|
}, [employeeEmail]);
|
||||||
|
|
||||||
const partitionsByType = React.useMemo(() => {
|
const partitionsByType = React.useMemo(() => {
|
||||||
const map = new Map<number, RequestListPartition[]>();
|
const map = new Map<number, RequestListPartition[]>();
|
||||||
@@ -168,52 +195,123 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
|
|||||||
const [replaceScope, setReplaceScope] = React.useState<IReplaceScope | undefined>();
|
const [replaceScope, setReplaceScope] = React.useState<IReplaceScope | undefined>();
|
||||||
const [resultToast, setResultToast] = React.useState<{ message: string; kind: 'ok' | 'err' } | undefined>();
|
const [resultToast, setResultToast] = React.useState<{ message: string; kind: 'ok' | 'err' } | undefined>();
|
||||||
|
|
||||||
async function bulkReplaceLive(rows: UserApprovalRow[], email: string, position: string): Promise<{ ok: number; fail: number }> {
|
// Открытый диалог разрешения дубля + то, чем разбудить ожидающий его цикл
|
||||||
const settled = await Promise.all(rows.map(r => settle(requestManage.reassignApprover(r, r.id, email, position))));
|
// замены (см. askDuplicate). Пакетная обработка идёт ПОСЛЕДОВАТЕЛЬНО именно
|
||||||
const ok = settled.filter(s => s.ok).length;
|
// из-за этого диалога: параллельные ветки открыли бы N окон разом.
|
||||||
return { ok, fail: settled.length - ok };
|
const [duplicatePrompt, setDuplicatePrompt] =
|
||||||
|
React.useState<{ message: string; resolve: (choice: DuplicateChoice) => void } | undefined>();
|
||||||
|
// «…для всех» на время ОДНОЙ пакетной операции — ref, а не state: значение
|
||||||
|
// читается внутри уже запущенного цикла, и перерисовка ему не нужна.
|
||||||
|
const duplicatePolicy = React.useRef<DuplicateChoice | undefined>(undefined);
|
||||||
|
|
||||||
|
const askDuplicate = (message: string): Promise<DuplicateChoice> => {
|
||||||
|
if (duplicatePolicy.current) return Promise.resolve(duplicatePolicy.current);
|
||||||
|
return new Promise<DuplicateChoice>(resolve => setDuplicatePrompt({ message, resolve }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const answerDuplicate = (choice: DuplicateChoice, forAll: boolean): void => {
|
||||||
|
if (forAll) duplicatePolicy.current = choice;
|
||||||
|
const pending = duplicatePrompt;
|
||||||
|
setDuplicatePrompt(undefined);
|
||||||
|
pending?.resolve(choice);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Замена в живых заявках. Дубль (тот же человек уже стоит в этой же фазе этой
|
||||||
|
* же заявки) SharePoint сам не запрещает — проверяем до записи, иначе в
|
||||||
|
* маршруте появились бы две строки на одного человека, и та же заявка ушла бы
|
||||||
|
* ему на согласование дважды.
|
||||||
|
*/
|
||||||
|
async function bulkReplaceLive(rows: UserApprovalRow[], email: string, position: string): Promise<IBulkOutcome> {
|
||||||
|
const outcome: IBulkOutcome = { ok: 0, fail: 0, deleted: 0, skipped: 0 };
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
try {
|
||||||
|
const duplicate = await requestManage.hasApproverInSamePhase(row, email);
|
||||||
|
if (duplicate) {
|
||||||
|
const label = `${row.requestUniqueCode || `#${row.ticketId}`} ${row.requestTitle}`;
|
||||||
|
const choice = await askDuplicate(texts.manageByUser.duplicateLiveMessage(label, row.position, email));
|
||||||
|
if (choice === 'skip') {
|
||||||
|
outcome.skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await requestManage.removeRow(row, row.id);
|
||||||
|
outcome.deleted += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await requestManage.reassignApprover(row, row.id, email, position);
|
||||||
|
outcome.ok += 1;
|
||||||
|
} catch (e) {
|
||||||
|
reportError(e, 'ManageByUserScreen: bulkReplaceLive');
|
||||||
|
outcome.fail += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return outcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bulkReplaceTemplates(
|
/**
|
||||||
rows: UserTemplateStepRow[],
|
* Замена в шаблонах маршрутов. Здесь дубль ловит СЕРВЕР (HasDuplicateAsync в
|
||||||
email: string
|
* TemplateRouteStepService → 409) — своей предварительной проверки не делаем,
|
||||||
): Promise<{ ok: number; fail: number; conflicts: string[] }> {
|
* его правило шире email'а (совпадать должны ещё статус и условие появления),
|
||||||
const settled = await Promise.all(
|
* и дублировать это на клиенте значило бы разъехаться с ним при первой же
|
||||||
rows.map(r => settle(api.templateRouteSteps.patch(r.routeId, r.id, { executorEmail: email })))
|
* правке.
|
||||||
);
|
*/
|
||||||
let ok = 0;
|
async function bulkReplaceTemplates(rows: UserTemplateStepRow[], email: string): Promise<IBulkOutcome> {
|
||||||
const conflicts: string[] = [];
|
const outcome: IBulkOutcome = { ok: 0, fail: 0, deleted: 0, skipped: 0 };
|
||||||
settled.forEach((s, i) => {
|
|
||||||
if (s.ok) {
|
for (const row of rows) {
|
||||||
ok += 1;
|
try {
|
||||||
return;
|
await api.templateRouteSteps.patch(row.routeId, row.id, { executorEmail: email });
|
||||||
|
outcome.ok += 1;
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as ApiClientError)?.status !== 409) {
|
||||||
|
reportError(e, 'ManageByUserScreen: bulkReplaceTemplates');
|
||||||
|
outcome.fail += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const label = `${row.requestTypeName} — ${row.companyName}`;
|
||||||
|
const choice = await askDuplicate(texts.manageByUser.duplicateTemplateMessage(label, row.orderNo, email));
|
||||||
|
if (choice === 'skip') {
|
||||||
|
outcome.skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.templateRouteSteps.remove(row.routeId, row.id);
|
||||||
|
outcome.deleted += 1;
|
||||||
|
} catch (removeError) {
|
||||||
|
reportError(removeError, 'ManageByUserScreen: bulkReplaceTemplates/remove');
|
||||||
|
outcome.fail += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const reason = s.error as ApiClientError;
|
}
|
||||||
if (reason?.status === 409) {
|
|
||||||
const row = rows[i];
|
return outcome;
|
||||||
conflicts.push(texts.manageByUser.templateConflict(`${row.requestTypeName} — ${row.companyName}`, row.orderNo));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return { ok, fail: settled.length - ok, conflicts };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReplaceConfirm = async (email: string): Promise<void> => {
|
const handleReplaceConfirm = async (email: string): Promise<void> => {
|
||||||
if (!replaceScope) return;
|
if (!replaceScope) return;
|
||||||
const position = findEmployeeByEmail(employees.items, email)?.position || '';
|
const position = findEmployeeByEmail(employees.items, email)?.position || '';
|
||||||
const messages: string[] = [];
|
const messages: string[] = [];
|
||||||
|
// Политика «…для всех» живёт ровно одну операцию — иначе следующая замена
|
||||||
|
// молча унаследовала бы решение, принятое для совсем другого списка строк.
|
||||||
|
duplicatePolicy.current = undefined;
|
||||||
|
|
||||||
|
// Модалку выбора нового согласующего закрываем сразу: дальше могут
|
||||||
|
// открываться диалоги дублей, и держать два окна одновременно незачем.
|
||||||
|
setReplaceScope(undefined);
|
||||||
|
|
||||||
if (replaceScope.live.length > 0) {
|
if (replaceScope.live.length > 0) {
|
||||||
const { ok, fail } = await bulkReplaceLive(replaceScope.live, email, position);
|
const r = await bulkReplaceLive(replaceScope.live, email, position);
|
||||||
messages.push(texts.manageByUser.liveResult(ok, fail));
|
messages.push(texts.manageByUser.liveResult(r.ok, r.fail, r.deleted, r.skipped));
|
||||||
}
|
}
|
||||||
if (replaceScope.templates.length > 0) {
|
if (replaceScope.templates.length > 0) {
|
||||||
const { ok, fail, conflicts } = await bulkReplaceTemplates(replaceScope.templates, email);
|
const r = await bulkReplaceTemplates(replaceScope.templates, email);
|
||||||
messages.push(texts.manageByUser.templatesResult(ok, fail));
|
messages.push(texts.manageByUser.templatesResult(r.ok, r.fail, r.deleted, r.skipped));
|
||||||
messages.push(...conflicts);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
duplicatePolicy.current = undefined;
|
||||||
setResultToast({ message: messages.join(' '), kind: 'ok' });
|
setResultToast({ message: messages.join(' '), kind: 'ok' });
|
||||||
setReplaceScope(undefined);
|
|
||||||
setCheckedLiveKeys(new Set());
|
setCheckedLiveKeys(new Set());
|
||||||
setCheckedTemplateIds(new Set());
|
setCheckedTemplateIds(new Set());
|
||||||
reloadLiveRows();
|
reloadLiveRows();
|
||||||
@@ -362,6 +460,14 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{duplicatePrompt && (
|
||||||
|
<ManageByUserDuplicateModal
|
||||||
|
title={texts.manageByUser.duplicateModalTitle}
|
||||||
|
message={duplicatePrompt.message}
|
||||||
|
onChoice={answerDuplicate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{resultToast && (
|
{resultToast && (
|
||||||
<Toast message={resultToast.message} kind={resultToast.kind} onClose={() => setResultToast(undefined)} />
|
<Toast message={resultToast.message} kind={resultToast.kind} onClose={() => setResultToast(undefined)} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ beforeEach(() => {
|
|||||||
|
|
||||||
describe('parseRoute', () => {
|
describe('parseRoute', () => {
|
||||||
it('без aatab — вкладка по умолчанию dashboard, всё остальное пусто', () => {
|
it('без aatab — вкладка по умолчанию dashboard, всё остальное пусто', () => {
|
||||||
expect(parseRoute('')).toEqual({ tab: 'dashboard', routeId: undefined, resolve: undefined, manage: undefined });
|
expect(parseRoute('')).toEqual({
|
||||||
|
tab: 'dashboard',
|
||||||
|
routeId: undefined,
|
||||||
|
resolve: undefined,
|
||||||
|
manage: undefined,
|
||||||
|
manageByUser: undefined
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('незнакомое значение aatab тоже откатывается на dashboard', () => {
|
it('незнакомое значение aatab тоже откатывается на dashboard', () => {
|
||||||
@@ -32,6 +38,16 @@ describe('parseRoute', () => {
|
|||||||
expect(route.manage).toBeUndefined();
|
expect(route.manage).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('manageByUser с aabuser — email сотрудника читается в manageByUser', () => {
|
||||||
|
const route = parseRoute('?aatab=manageByUser&aabuser=ivanov%40tnsu.kz');
|
||||||
|
expect(route.tab).toBe('manageByUser');
|
||||||
|
expect(route.manageByUser).toEqual({ userEmail: 'ivanov@tnsu.kz' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aabuser учитывается только при tab=manageByUser', () => {
|
||||||
|
expect(parseRoute('?aatab=dashboard&aabuser=ivanov%40tnsu.kz').manageByUser).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('aaroute учитывается только при tab=routes', () => {
|
it('aaroute учитывается только при tab=routes', () => {
|
||||||
expect(parseRoute('?aatab=routes&aaroute=7').routeId).toBe(7);
|
expect(parseRoute('?aatab=routes&aaroute=7').routeId).toBe(7);
|
||||||
expect(parseRoute('?aatab=manageRequests&aaroute=7').routeId).toBeUndefined();
|
expect(parseRoute('?aatab=manageRequests&aaroute=7').routeId).toBeUndefined();
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ export interface IResolveRouteParams {
|
|||||||
departmentId: string;
|
departmentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IManageByUserRouteParams {
|
||||||
|
/** Email выбранного сотрудника — он однозначно определяет весь экран (обе панели). */
|
||||||
|
userEmail: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IManageRequestRouteParams {
|
export interface IManageRequestRouteParams {
|
||||||
requestTypeId: string;
|
requestTypeId: string;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
@@ -61,6 +66,8 @@ export interface IAdminRoute {
|
|||||||
resolve?: IResolveRouteParams;
|
resolve?: IResolveRouteParams;
|
||||||
/** Задан только при tab='manageRequests' и непустых requestTypeId+requestId. */
|
/** Задан только при tab='manageRequests' и непустых requestTypeId+requestId. */
|
||||||
manage?: IManageRequestRouteParams;
|
manage?: IManageRequestRouteParams;
|
||||||
|
/** Задан только при tab='manageByUser' и непустом userEmail. */
|
||||||
|
manageByUser?: IManageByUserRouteParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROUTE_CHANGE_EVENT = 'aa-route-change';
|
const ROUTE_CHANGE_EVENT = 'aa-route-change';
|
||||||
@@ -72,7 +79,8 @@ const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'ma
|
|||||||
const PARAM_KEYS = [
|
const PARAM_KEYS = [
|
||||||
'aatab', 'aaroute', 'aarfilter',
|
'aatab', 'aaroute', 'aarfilter',
|
||||||
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept',
|
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept',
|
||||||
'aamtype', 'aamreq', 'aamlist'
|
'aamtype', 'aamreq', 'aamlist',
|
||||||
|
'aabuser'
|
||||||
];
|
];
|
||||||
|
|
||||||
// raw — результат URLSearchParams.get() (DOM API, возвращает string | null).
|
// raw — результат URLSearchParams.get() (DOM API, возвращает string | null).
|
||||||
@@ -125,12 +133,15 @@ export function parseRoute(search: string = window.location.search): IAdminRoute
|
|||||||
? { requestTypeId: manageTypeId, requestId: manageReqId, listId: params.get('aamlist') || '' }
|
? { requestTypeId: manageTypeId, requestId: manageReqId, listId: params.get('aamlist') || '' }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const byUserEmail = tab === 'manageByUser' ? params.get('aabuser') : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tab,
|
tab,
|
||||||
routeId: routeId && !isNaN(routeId) ? routeId : undefined,
|
routeId: routeId && !isNaN(routeId) ? routeId : undefined,
|
||||||
routesFilter: (tab === 'routes' ? params.get('aarfilter') : null) || undefined,
|
routesFilter: (tab === 'routes' ? params.get('aarfilter') : null) || undefined,
|
||||||
resolve,
|
resolve,
|
||||||
manage
|
manage,
|
||||||
|
manageByUser: byUserEmail ? { userEmail: byUserEmail } : undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +174,10 @@ function buildQuery(route: IAdminRoute): string {
|
|||||||
if (route.manage.listId) params.set('aamlist', route.manage.listId);
|
if (route.manage.listId) params.set('aamlist', route.manage.listId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (route.tab === 'manageByUser' && route.manageByUser?.userEmail) {
|
||||||
|
params.set('aabuser', route.manageByUser.userEmail);
|
||||||
|
}
|
||||||
|
|
||||||
const query = params.toString();
|
const query = params.toString();
|
||||||
return `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`;
|
return `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -486,6 +486,36 @@ export class RequestManageService {
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Появится ли в фазе этой заявки ВТОРОЙ согласующий с данным email, если строку
|
||||||
|
* row переназначить на него — та же проверка, что ManageRequestsScreen делает
|
||||||
|
* через excludedEmails при ручном переназначении (там она бесплатна: вся
|
||||||
|
* матрица заявки уже загружена), но здесь массовая замена бьёт по многим
|
||||||
|
* заявкам разом и своей матрицы для каждой не держит, поэтому запрашивается
|
||||||
|
* отдельно. Как и excludedEmails, смотрит на ВСЕ строки фазы независимо от
|
||||||
|
* статуса (в т.ч. уже решённые) — правило «один человек не может стоять в
|
||||||
|
* фазе дважды» не знает исключений по статусу.
|
||||||
|
*/
|
||||||
|
public async hasApproverInSamePhase(
|
||||||
|
row: Pick<UserApprovalRow, 'siteUrl' | 'approvalListTitle' | 'ticketId' | 'phase' | 'id'>,
|
||||||
|
email: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const sp = this.clientFor(row.siteUrl);
|
||||||
|
const rows = (await sp.web.lists
|
||||||
|
.getByTitle(row.approvalListTitle)
|
||||||
|
.items.filter(`${FIELDS.ticket}Id eq ${row.ticketId} and ${FIELDS.phase} eq '${row.phase}'`)
|
||||||
|
.select('Id', `${FIELDS.approver}/EMail`)
|
||||||
|
.expand(FIELDS.approver)
|
||||||
|
.top(200)()) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
const target = email.toLowerCase();
|
||||||
|
return rows.some(r => {
|
||||||
|
if ((r.Id as number) === row.id) return false;
|
||||||
|
const approver = r[FIELDS.approver] as { EMail?: string } | undefined;
|
||||||
|
return (approver?.EMail || '').toLowerCase() === target;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Переназначить согласующего/исполнителя строки. Вызывающая сторона обязана
|
/** Переназначить согласующего/исполнителя строки. Вызывающая сторона обязана
|
||||||
* проверить isEditableRowStatus(row.status) — сервис это не перепроверяет. */
|
* проверить isEditableRowStatus(row.status) — сервис это не перепроверяет. */
|
||||||
public async reassignApprover(
|
public async reassignApprover(
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export const texts = {
|
|||||||
routes: 'Маршруты',
|
routes: 'Маршруты',
|
||||||
resolve: 'Проверка маршрута',
|
resolve: 'Проверка маршрута',
|
||||||
manageRequests: 'Редактирование заявок',
|
manageRequests: 'Редактирование заявок',
|
||||||
manageByUser: 'Заявки по согласующему'
|
manageByUser: 'Заявки по участнику'
|
||||||
},
|
},
|
||||||
|
|
||||||
app: {
|
app: {
|
||||||
@@ -442,9 +442,9 @@ export const texts = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
manageByUser: {
|
manageByUser: {
|
||||||
breadcrumb: 'Заявки по согласующему',
|
breadcrumb: 'Заявки по участнику',
|
||||||
subtitle: 'Массовая замена согласующего/исполнителя',
|
subtitle: 'Массовая замена согласующего/исполнителя',
|
||||||
title: 'Заявки по согласующему',
|
title: 'Заявки по участнику',
|
||||||
employeeLabel: 'Сотрудник',
|
employeeLabel: 'Сотрудник',
|
||||||
employeePlaceholder: 'Выберите сотрудника',
|
employeePlaceholder: 'Выберите сотрудника',
|
||||||
employeeHint: 'Покажет все его активные заявки (по всем типам и партициям) и все шаблонные шаги, где он назначен лично.',
|
employeeHint: 'Покажет все его активные заявки (по всем типам и партициям) и все шаблонные шаги, где он назначен лично.',
|
||||||
@@ -471,12 +471,31 @@ export const texts = {
|
|||||||
newEmployeeLabel: 'Сотрудник',
|
newEmployeeLabel: 'Сотрудник',
|
||||||
newEmployeePlaceholder: 'Выберите нового согласующего',
|
newEmployeePlaceholder: 'Выберите нового согласующего',
|
||||||
validationEmployee: 'Выберите сотрудника.',
|
validationEmployee: 'Выберите сотрудника.',
|
||||||
liveResult: (ok: number, fail: number): string =>
|
liveResult: (ok: number, fail: number, deleted: number, skipped: number): string => {
|
||||||
fail > 0 ? `Заявки: заменено ${ok}, не удалось ${fail}.` : `Заявки: заменено ${ok}.`,
|
const parts = [`заменено ${ok}`];
|
||||||
templatesResult: (ok: number, fail: number): string =>
|
if (deleted > 0) parts.push(`удалено дублей ${deleted}`);
|
||||||
fail > 0 ? `Шаблоны: заменено ${ok}, не удалось ${fail}.` : `Шаблоны: заменено ${ok}.`,
|
if (skipped > 0) parts.push(`пропущено ${skipped}`);
|
||||||
templateConflict: (routeLabel: string, orderNo: number): string =>
|
if (fail > 0) parts.push(`не удалось ${fail}`);
|
||||||
`В маршруте «${routeLabel}» уже есть шаг с этим исполнителем — шаг №${orderNo} не изменён.`
|
return `Заявки: ${parts.join(', ')}.`;
|
||||||
|
},
|
||||||
|
templatesResult: (ok: number, fail: number, deleted: number, skipped: number): string => {
|
||||||
|
const parts = [`заменено ${ok}`];
|
||||||
|
if (deleted > 0) parts.push(`удалено дублей ${deleted}`);
|
||||||
|
if (skipped > 0) parts.push(`пропущено ${skipped}`);
|
||||||
|
if (fail > 0) parts.push(`не удалось ${fail}`);
|
||||||
|
return `Шаблоны: ${parts.join(', ')}.`;
|
||||||
|
},
|
||||||
|
duplicateModalTitle: 'Уже назначен на этом же шаге',
|
||||||
|
duplicateLiveMessage: (requestLabel: string, oldPosition: string, newEmail: string): string =>
|
||||||
|
`В заявке ${requestLabel} сотрудник ${newEmail} уже согласует/исполняет этот же шаг. ` +
|
||||||
|
`Заменить строку («${oldPosition || 'без должности'}») тоже на него нельзя — появится дубль. Удалить эту строку?`,
|
||||||
|
duplicateTemplateMessage: (routeLabel: string, orderNo: number, newEmail: string): string =>
|
||||||
|
`В маршруте «${routeLabel}» сотрудник ${newEmail} уже назначен на шаг с такими же параметрами. ` +
|
||||||
|
`Заменить шаг №${orderNo} на него нельзя — сервер вернул конфликт. Удалить этот шаг?`,
|
||||||
|
duplicateDelete: 'Да, удалить',
|
||||||
|
duplicateDeleteAll: 'Да для всех',
|
||||||
|
duplicateSkip: 'Нет, пропустить',
|
||||||
|
duplicateSkipAll: 'Нет для всех'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Значения по умолчанию для переиспользуемых контролов выбора.
|
// Значения по умолчанию для переиспользуемых контролов выбора.
|
||||||
|
|||||||
Reference in New Issue
Block a user