вроде все работает

This commit is contained in:
gorbenko.pavel
2026-08-19 17:38:17 +05:00
parent 5ae9fbd64b
commit c22efe5377
13 changed files with 499 additions and 159 deletions
@@ -1,6 +1,5 @@
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';
@@ -12,6 +11,7 @@ import { shortGuid } from '../../utils/guid';
import { describeStepCondition } from '../../utils/stepExecutor';
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
import { reportError } from '../../utils/errors';
import { mapWithLimit } from '../../utils/parallel';
import { IManageByUserRouteParams, replaceRoute } from '../../routing';
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { useRequestTypes } from '../../hooks/useRequestTypes';
@@ -36,6 +36,7 @@ import {
PageHeader,
Panel,
PrimaryCell,
ProgressOverlay,
SearchableSelect,
Toast
} from '../ui';
@@ -49,9 +50,16 @@ export interface IManageByUserScreenProps {
route?: IManageByUserRouteParams;
}
// Выбранный сотрудник переживает перезагрузку страницы — как и выбор заявки в
// ManageRequestsScreen (тот же приём, отдельный ключ).
const SELECTION_STORAGE_KEY = 'approvalAdmin.manageByUserEmail';
// Выбор (кого меняем и на кого) переживает перезагрузку страницы — как и выбор
// заявки в ManageRequestsScreen (тот же приём, отдельный ключ).
const SELECTION_STORAGE_KEY = 'approvalAdmin.manageByUserSelection';
interface IPersistedSelection {
employeeEmail: string;
targetEmail: string;
}
const EMPTY_SELECTION: IPersistedSelection = { employeeEmail: '', targetEmail: '' };
// Активные статусы строки для этого экрана — сознательно уже, чем
// isEditableRowStatus (models/requestManage.ts): «Возвращена на доработку» и
@@ -91,13 +99,19 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
const employees = usePortalEmployees(dictionary);
const dictionaries = usePortalDictionaries(dictionary);
const [employeeEmail, setEmployeeEmail] = useLocalStorageState<string>(SELECTION_STORAGE_KEY, '');
const [selection, setSelection] = useLocalStorageState<IPersistedSelection>(SELECTION_STORAGE_KEY, EMPTY_SELECTION);
const { employeeEmail, targetEmail } = selection;
// Открыли по глубокой ссылке (aabuser в URL) — перекрывает то, что было в
// localStorage. Только на монтировании: route меняется при каждом собственном
// replaceRoute ниже (тот же приём, что в ManageRequestsScreen/ResolveScreen).
const selectEmployee = (email: string): void => setSelection(prev => ({ ...prev, employeeEmail: email }));
const selectTarget = (email: string): void => setSelection(prev => ({ ...prev, targetEmail: email }));
const swapEmployees = (): void =>
setSelection(prev => ({ employeeEmail: prev.targetEmail, targetEmail: prev.employeeEmail }));
// Открыли по глубокой ссылке (aabuser/aabtarget в URL) — перекрывает то, что
// было в localStorage. Только на монтировании: route меняется при каждом
// собственном replaceRoute ниже (тот же приём, что в ManageRequestsScreen).
React.useEffect(() => {
if (route?.userEmail) setEmployeeEmail(route.userEmail);
if (route?.userEmail) setSelection({ employeeEmail: route.userEmail, targetEmail: route.targetEmail });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -107,9 +121,9 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
React.useEffect(() => {
replaceRoute({
tab: 'manageByUser',
manageByUser: employeeEmail ? { userEmail: employeeEmail } : undefined
manageByUser: employeeEmail ? { userEmail: employeeEmail, targetEmail } : undefined
});
}, [employeeEmail]);
}, [employeeEmail, targetEmail]);
const partitionsByType = React.useMemo(() => {
const map = new Map<number, RequestListPartition[]>();
@@ -192,8 +206,10 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
const toggleAllTemplates = (): void =>
setCheckedTemplateIds(allTemplatesChecked ? new Set() : new Set(templateRows.map(r => r.id)));
const [replaceScope, setReplaceScope] = React.useState<IReplaceScope | undefined>();
const [resultToast, setResultToast] = React.useState<{ message: string; kind: 'ok' | 'err' } | undefined>();
// Пока задан — интерфейс заблокирован подложкой с полосой прогресса
// (ProgressOverlay), и запустить вторую замену поверх первой нельзя.
const [progress, setProgress] = React.useState<{ done: number; total: number } | undefined>();
// Открытый диалог разрешения дубля + то, чем разбудить ожидающий его цикл
// замены (см. askDuplicate). Пакетная обработка идёт ПОСЛЕДОВАТЕЛЬНО именно
@@ -204,6 +220,11 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
// читается внутри уже запущенного цикла, и перерисовка ему не нужна.
const duplicatePolicy = React.useRef<DuplicateChoice | undefined>(undefined);
// Шаг вперёд по полосе прогресса. Вызывается ПЕРЕД обработкой очередной
// строки: показывает «делаю N-ю из M», а не «N уже готово».
const advanceProgress = (): void =>
setProgress(prev => (prev ? { done: Math.min(prev.done + 1, prev.total), total: prev.total } : prev));
const askDuplicate = (message: string): Promise<DuplicateChoice> => {
if (duplicatePolicy.current) return Promise.resolve(duplicatePolicy.current);
return new Promise<DuplicateChoice>(resolve => setDuplicatePrompt({ message, resolve }));
@@ -217,100 +238,159 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
};
/**
* Замена в живых заявках. Дубль (тот же человек уже стоит в этой же фазе этой
* же заявки) SharePoint сам не запрещает — проверяем до записи, иначе в
* маршруте появились бы две строки на одного человека, и та же заявка ушла бы
* ему на согласование дважды.
* Замена в живых заявках — тремя фазами, чтобы совместить параллельность с
* интерактивным разрешением дублей:
*
* 1. ПАРАЛЛЕЛЬНО проверяем каждую строку на дубль (чтение, ничего не пишет)
* и заодно параллельно заменяем все «чистые» строки;
* 2. ПОСЛЕДОВАТЕЛЬНО спрашиваем пользователя по каждому найденному дублю —
* диалог по определению один за раз (параллельно открылось бы N окон);
* 3. ПАРАЛЛЕЛЬНО удаляем те строки, по которым выбрали «удалить».
*
* Дубль (тот же человек уже стоит в этой же фазе этой же заявки) 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');
// Фаза 1: проверка дублей. Прогресс здесь не двигаем — это ещё не работа по
// строке, а подготовка (строка может оказаться и заменена, и удалена).
const checks = await mapWithLimit(rows, row => requestManage.hasApproverInSamePhase(row, email));
const clean: UserApprovalRow[] = [];
const conflicting: UserApprovalRow[] = [];
checks.forEach((result, i) => {
if (!result.ok) {
reportError(result.error, 'ManageByUserScreen: hasApproverInSamePhase');
outcome.fail += 1;
advanceProgress();
return;
}
(result.value ? conflicting : clean).push(rows[i]);
});
// Фаза 2: «чистые» строки — параллельно, друг от друга не зависят.
const replaced = await mapWithLimit(
clean,
row => requestManage.reassignApprover(row, row.id, email, position),
{ onSettled: advanceProgress }
);
replaced.forEach(result => {
if (result.ok) outcome.ok += 1;
else {
reportError(result.error, 'ManageByUserScreen: bulkReplaceLive/reassign');
outcome.fail += 1;
}
});
// Фаза 3: дубли — спрашиваем по одному, накапливая тех, кого удалять.
const toDelete: UserApprovalRow[] = [];
for (const row of conflicting) {
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;
advanceProgress();
} else {
toDelete.push(row);
}
}
const removed = await mapWithLimit(toDelete, row => requestManage.removeRow(row, row.id), {
onSettled: advanceProgress
});
removed.forEach(result => {
if (result.ok) outcome.deleted += 1;
else {
reportError(result.error, 'ManageByUserScreen: bulkReplaceLive/remove');
outcome.fail += 1;
}
});
return outcome;
}
/**
* Замена в шаблонах маршрутов. Здесь дубль ловит СЕРВЕР (HasDuplicateAsync в
* TemplateRouteStepService → 409) — своей предварительной проверки не делаем,
* Замена в шаблонах маршрутов — та же трёхфазная схема, что и для живых
* заявок, но дубль здесь ловит СЕРВЕР (HasDuplicateAsync в
* TemplateRouteStepService → 409): своей предварительной проверки не делаем,
* его правило шире email'а (совпадать должны ещё статус и условие появления),
* и дублировать это на клиенте значило бы разъехаться с ним при первой же
* правке.
* правке. Поэтому «проверка» и есть сам PATCH — он идёт параллельно, а
* разбираем 409-ые уже после.
*/
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 });
const patched = await mapWithLimit(
rows,
row => api.templateRouteSteps.patch(row.routeId, row.id, { executorEmail: email }),
{ onSettled: advanceProgress }
);
const conflicting: UserTemplateStepRow[] = [];
patched.forEach((result, i) => {
if (result.ok) {
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;
}
return;
}
if ((result.error as ApiClientError)?.status === 409) {
conflicting.push(rows[i]);
return;
}
reportError(result.error, 'ManageByUserScreen: bulkReplaceTemplates');
outcome.fail += 1;
});
const toDelete: UserTemplateStepRow[] = [];
for (const row of conflicting) {
const label = `${row.requestTypeName} — ${row.companyName}`;
const choice = await askDuplicate(texts.manageByUser.duplicateTemplateMessage(label, row.orderNo, email));
if (choice === 'skip') outcome.skipped += 1;
else toDelete.push(row);
}
const removed = await mapWithLimit(toDelete, row => api.templateRouteSteps.remove(row.routeId, row.id));
removed.forEach(result => {
if (result.ok) outcome.deleted += 1;
else {
reportError(result.error, 'ManageByUserScreen: bulkReplaceTemplates/remove');
outcome.fail += 1;
}
});
return outcome;
}
const handleReplaceConfirm = async (email: string): Promise<void> => {
if (!replaceScope) return;
const position = findEmployeeByEmail(employees.items, email)?.position || '';
const runReplace = async (scope: IReplaceScope): Promise<void> => {
const total = scope.live.length + scope.templates.length;
if (!targetEmail || total === 0 || progress) return;
const position = findEmployeeByEmail(employees.items, targetEmail)?.position || '';
const messages: string[] = [];
// Политика «…для всех» живёт ровно одну операцию — иначе следующая замена
// молча унаследовала бы решение, принятое для совсем другого списка строк.
duplicatePolicy.current = undefined;
setProgress({ done: 0, total });
// Модалку выбора нового согласующего закрываем сразу: дальше могут
// открываться диалоги дублей, и держать два окна одновременно незачем.
setReplaceScope(undefined);
if (replaceScope.live.length > 0) {
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 r = await bulkReplaceTemplates(replaceScope.templates, email);
messages.push(texts.manageByUser.templatesResult(r.ok, r.fail, r.deleted, r.skipped));
try {
if (scope.live.length > 0) {
const r = await bulkReplaceLive(scope.live, targetEmail, position);
messages.push(texts.manageByUser.liveResult(r.ok, r.fail, r.deleted, r.skipped));
}
if (scope.templates.length > 0) {
const r = await bulkReplaceTemplates(scope.templates, targetEmail);
messages.push(texts.manageByUser.templatesResult(r.ok, r.fail, r.deleted, r.skipped));
}
} finally {
// Снимать блокировку обязательно в finally: если что-то бросит мимо
// внутренних try/catch, подложка иначе осталась бы висеть навсегда и
// экран стал бы неработоспособен до перезагрузки страницы.
setProgress(undefined);
duplicatePolicy.current = undefined;
}
duplicatePolicy.current = undefined;
setResultToast({ message: messages.join(' '), kind: 'ok' });
setCheckedLiveKeys(new Set());
setCheckedTemplateIds(new Set());
@@ -318,6 +398,15 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
templateSteps.reload();
};
// Сам себя заменить нельзя — выбор бессмысленный (и в живых заявках сразу
// упёрся бы в проверку дубля), поэтому исходного сотрудника в списке
// получателей не показываем вовсе.
const targetOptions = employees.items
.filter(e => e.email.toLowerCase() !== employeeEmail.toLowerCase())
.map(toEmployeeOption);
// Пока получатель не выбран, менять нечего — все кнопки замены выключены.
const canReplace = !!employeeEmail && !!targetEmail;
const liveColumns: IColumn<UserApprovalRow>[] = [
{ key: 'check', header: '', width: '32px', render: r => <Checkbox label="" checked={checkedLiveKeys.has(liveRowKey(r))} onChange={() => toggleLive(r)} /> },
{ key: 'type', header: texts.manageByUser.columnRequestType, render: r => r.requestTypeName },
@@ -335,7 +424,7 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
sticky: true,
render: r => (
<ActionsCell>
<IconButton icon="edit" label={texts.manageByUser.replaceOne} onClick={() => setReplaceScope({ live: [r], templates: [] })} />
<IconButton icon="edit" label={texts.manageByUser.replaceOne} disabled={!canReplace} onClick={() => runReplace({ live: [r], templates: [] })} />
</ActionsCell>
)
}
@@ -356,7 +445,7 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
sticky: true,
render: r => (
<ActionsCell>
<IconButton icon="edit" label={texts.manageByUser.replaceOne} onClick={() => setReplaceScope({ live: [], templates: [r] })} />
<IconButton icon="edit" label={texts.manageByUser.replaceOne} disabled={!canReplace} onClick={() => runReplace({ live: [], templates: [r] })} />
</ActionsCell>
)
}
@@ -374,8 +463,8 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
actions={
<Button
variant="accent"
disabled={!employeeEmail || !hasAnyRows}
onClick={() => setReplaceScope({ live: liveRows, templates: templateRows })}
disabled={!canReplace || !hasAnyRows}
onClick={() => runReplace({ live: liveRows, templates: templateRows })}
>
{texts.manageByUser.replaceEverywhere}
</Button>
@@ -384,7 +473,7 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
<Panel>
<FormGrid>
<Field label={texts.manageByUser.employeeLabel} required hint={texts.manageByUser.employeeHint} full>
<Field label={texts.manageByUser.employeeLabel} required hint={texts.manageByUser.employeeHint}>
<SearchableSelect
options={employees.items.map(toEmployeeOption)}
value={employeeEmail}
@@ -392,9 +481,39 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
placeholder={texts.manageByUser.employeePlaceholder}
searchPlaceholder={texts.select.employeeSearchPlaceholder}
emptyMessage={texts.select.employeesEmpty}
onChange={setEmployeeEmail}
onChange={selectEmployee}
/>
</Field>
{/* Кого ставим вместо — рядом, а не в модалке по кнопке: замена обычно
идёт в несколько приёмов (сначала заявки, потом маршруты, потом
отдельные строки), и выбирать одного и того же человека заново
на каждый приём незачем. */}
<Field label={texts.manageByUser.targetLabel} required hint={texts.manageByUser.targetHint}>
<div className={styles.targetRow}>
<div className={styles.targetSelect}>
<SearchableSelect
options={targetOptions}
value={targetEmail}
loading={employees.isLoading}
placeholder={texts.manageByUser.targetPlaceholder}
searchPlaceholder={texts.select.employeeSearchPlaceholder}
emptyMessage={texts.select.employeesEmpty}
allowClear
clearLabel={texts.manageByUser.targetClear}
onChange={selectTarget}
/>
</div>
{/* Обратная замена — обычное продолжение работы («вернуть всё
как было», «а теперь наоборот»), и переставлять двух человек
руками через два поиска по справочнику ради этого не нужно. */}
<IconButton
icon="sort"
label={texts.manageByUser.swap}
disabled={!employeeEmail && !targetEmail}
onClick={swapEmployees}
/>
</div>
</Field>
</FormGrid>
</Panel>
@@ -409,8 +528,8 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
<Button
variant="secondary"
size="sm"
disabled={checkedLiveRows.length === 0}
onClick={() => setReplaceScope({ live: checkedLiveRows, templates: [] })}
disabled={!canReplace || checkedLiveRows.length === 0}
onClick={() => runReplace({ live: checkedLiveRows, templates: [] })}
>
{texts.manageByUser.replaceSelected(checkedLiveRows.length)}
</Button>
@@ -435,8 +554,8 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
<Button
variant="secondary"
size="sm"
disabled={checkedTemplateRows.length === 0}
onClick={() => setReplaceScope({ live: [], templates: checkedTemplateRows })}
disabled={!canReplace || checkedTemplateRows.length === 0}
onClick={() => runReplace({ live: [], templates: checkedTemplateRows })}
>
{texts.manageByUser.replaceSelected(checkedTemplateRows.length)}
</Button>
@@ -450,14 +569,8 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
</Panel>
)}
{replaceScope && (
<ManageByUserReplaceModal
title={texts.manageByUser.replaceModalTitle}
employees={employees.items}
employeesLoading={employees.isLoading}
onSave={handleReplaceConfirm}
onClose={() => setReplaceScope(undefined)}
/>
{progress && (
<ProgressOverlay label={texts.manageByUser.progressLabel} done={progress.done} total={progress.total} />
)}
{duplicatePrompt && (