много правок
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import styles from './ManageByUserScreen.module.scss';
|
||||
import { ManageByUserReplaceModal } from './ManageByUserReplaceModal';
|
||||
import { DuplicateChoice, ManageByUserDuplicateModal } from './ManageByUserDuplicateModal';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||
import { RequestManageService } from '../../services/requestManageService';
|
||||
@@ -11,6 +12,8 @@ import { shortGuid } from '../../utils/guid';
|
||||
import { describeStepCondition } from '../../utils/stepExecutor';
|
||||
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
||||
import { reportError } from '../../utils/errors';
|
||||
import { IManageByUserRouteParams, replaceRoute } from '../../routing';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { useRequestTypeListsAll } from '../../hooks/useRequestTypeListsAll';
|
||||
import { useTemplateRouteStepsByExecutor } from '../../hooks/useTemplateRouteStepsByExecutor';
|
||||
@@ -42,21 +45,27 @@ export interface IManageByUserScreenProps {
|
||||
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||
dictionary?: PortalDictionaryClient;
|
||||
requestManage: RequestManageService;
|
||||
/** Заполнение из URL (глубокая ссылка на конкретного сотрудника) — см. routing.ts. */
|
||||
route?: IManageByUserRouteParams;
|
||||
}
|
||||
|
||||
// Выбранный сотрудник переживает перезагрузку страницы — как и выбор заявки в
|
||||
// ManageRequestsScreen (тот же приём, отдельный ключ).
|
||||
const SELECTION_STORAGE_KEY = 'approvalAdmin.manageByUserEmail';
|
||||
|
||||
// Активные статусы строки для этого экрана — сознательно уже, чем
|
||||
// isEditableRowStatus (models/requestManage.ts): «Возвращена на доработку» и
|
||||
// черновики сюда не входят (согласовано отдельно, см. план реализации).
|
||||
const ACTIVE_STATUSES: string[] = [REQUEST_STATUS.queued, REQUEST_STATUS.onApproval, REQUEST_STATUS.onExecution];
|
||||
|
||||
type Settled<T> = { ok: true; value: T } | { ok: false; error: unknown };
|
||||
|
||||
// Promise.allSettled недоступен в целевом lib этого проекта (tsconfig.json —
|
||||
// es5/es2015.*, без es2020.promise) — свой аналог вместо него: массовая замена
|
||||
// не должна падать целиком из-за одной сломанной строки/шага (см.
|
||||
// bulkReplaceLive/bulkReplaceTemplates ниже).
|
||||
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 }));
|
||||
// Итог пакетной операции по одной панели.
|
||||
interface IBulkOutcome {
|
||||
ok: number;
|
||||
fail: number;
|
||||
/** Дубли, снятые удалением старой строки/шага (пользователь выбрал «удалить»). */
|
||||
deleted: number;
|
||||
/** Дубли, оставленные как есть (пользователь выбрал «пропустить»). */
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
// Та же цветовая группировка статуса заявки, что и в ManageRequestsScreen
|
||||
@@ -76,13 +85,31 @@ interface IReplaceScope {
|
||||
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 listSeries = useRequestTypeListsAll(api);
|
||||
const employees = usePortalEmployees(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 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 [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))));
|
||||
const ok = settled.filter(s => s.ok).length;
|
||||
return { ok, fail: settled.length - ok };
|
||||
// Открытый диалог разрешения дубля + то, чем разбудить ожидающий его цикл
|
||||
// замены (см. askDuplicate). Пакетная обработка идёт ПОСЛЕДОВАТЕЛЬНО именно
|
||||
// из-за этого диалога: параллельные ветки открыли бы N окон разом.
|
||||
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[],
|
||||
email: string
|
||||
): Promise<{ ok: number; fail: number; conflicts: string[] }> {
|
||||
const settled = await Promise.all(
|
||||
rows.map(r => settle(api.templateRouteSteps.patch(r.routeId, r.id, { executorEmail: email })))
|
||||
);
|
||||
let ok = 0;
|
||||
const conflicts: string[] = [];
|
||||
settled.forEach((s, i) => {
|
||||
if (s.ok) {
|
||||
ok += 1;
|
||||
return;
|
||||
/**
|
||||
* Замена в шаблонах маршрутов. Здесь дубль ловит СЕРВЕР (HasDuplicateAsync в
|
||||
* TemplateRouteStepService → 409) — своей предварительной проверки не делаем,
|
||||
* его правило шире email'а (совпадать должны ещё статус и условие появления),
|
||||
* и дублировать это на клиенте значило бы разъехаться с ним при первой же
|
||||
* правке.
|
||||
*/
|
||||
async function bulkReplaceTemplates(rows: UserTemplateStepRow[], email: string): Promise<IBulkOutcome> {
|
||||
const outcome: IBulkOutcome = { ok: 0, fail: 0, deleted: 0, skipped: 0 };
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
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];
|
||||
conflicts.push(texts.manageByUser.templateConflict(`${row.requestTypeName} — ${row.companyName}`, row.orderNo));
|
||||
}
|
||||
});
|
||||
return { ok, fail: settled.length - ok, conflicts };
|
||||
}
|
||||
|
||||
return outcome;
|
||||
}
|
||||
|
||||
const handleReplaceConfirm = async (email: string): Promise<void> => {
|
||||
if (!replaceScope) return;
|
||||
const position = findEmployeeByEmail(employees.items, email)?.position || '';
|
||||
const messages: string[] = [];
|
||||
// Политика «…для всех» живёт ровно одну операцию — иначе следующая замена
|
||||
// молча унаследовала бы решение, принятое для совсем другого списка строк.
|
||||
duplicatePolicy.current = undefined;
|
||||
|
||||
// Модалку выбора нового согласующего закрываем сразу: дальше могут
|
||||
// открываться диалоги дублей, и держать два окна одновременно незачем.
|
||||
setReplaceScope(undefined);
|
||||
|
||||
if (replaceScope.live.length > 0) {
|
||||
const { ok, fail } = await bulkReplaceLive(replaceScope.live, email, position);
|
||||
messages.push(texts.manageByUser.liveResult(ok, fail));
|
||||
const r = await bulkReplaceLive(replaceScope.live, email, position);
|
||||
messages.push(texts.manageByUser.liveResult(r.ok, r.fail, r.deleted, r.skipped));
|
||||
}
|
||||
if (replaceScope.templates.length > 0) {
|
||||
const { ok, fail, conflicts } = await bulkReplaceTemplates(replaceScope.templates, email);
|
||||
messages.push(texts.manageByUser.templatesResult(ok, fail));
|
||||
messages.push(...conflicts);
|
||||
const r = await bulkReplaceTemplates(replaceScope.templates, email);
|
||||
messages.push(texts.manageByUser.templatesResult(r.ok, r.fail, r.deleted, r.skipped));
|
||||
}
|
||||
|
||||
duplicatePolicy.current = undefined;
|
||||
setResultToast({ message: messages.join(' '), kind: 'ok' });
|
||||
setReplaceScope(undefined);
|
||||
setCheckedLiveKeys(new Set());
|
||||
setCheckedTemplateIds(new Set());
|
||||
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 && (
|
||||
<Toast message={resultToast.message} kind={resultToast.kind} onClose={() => setResultToast(undefined)} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user